Forward-merge release/26.08 into main - #8421
Merged
Merged
Conversation
New version of the Isolation Forest PR. This PR introduces a high-performance GPU implementation of the Isolation Forest algorithm for unsupervised anomaly detection. The implementation can achieve from tens to thousands of times speedup over scikit-learn depending on hardware, dataset, hyperparameters, and input layout, scaling to datasets of 25M+ samples while maintaining equivalent detection quality (ROC-AUC). > **Update**: This PR now includes Treelite export and nvForest-backed inference. The trained cuML model exports its compact isolation trees to a Treelite regression forest that predicts average path length; cuML then applies the standard Isolation Forest score transform to preserve sklearn-compatible `score_samples()` semantics. --- ## Algorithm ### Background Isolation Forest [(Liu et al., 2008)](https://ieeexplore.ieee.org/document/4781136) detects anomalies by exploiting the key insight that anomalies are few and different, and therefore they are more susceptible to isolation than normal points. Rather than profiling normal behavior, the algorithm directly isolates anomalies using random recursive partitioning. **Core principle**: Anomalies have shorter average path lengths in randomly constructed trees because they are easier to separate from the majority of data. ### Algorithm Description ``` IsolationForest(X, n_trees, max_samples): for t in 1..n_trees: subsample = random_sample(X, max_samples) # Default: 256 samples tree = build_isolation_tree(subsample, max_depth=ceil(log2(max_samples))) forest.append(tree) build_isolation_tree(X, depth): if depth == 0 or |X| <= 1: return Leaf(size=|X|) feature = random_choice(features) threshold = random_uniform(min(X[feature]), max(X[feature])) left = X[X[feature] < threshold] right = X[X[feature] >= threshold] return Node(feature, threshold, build_isolation_tree(left, depth-1), build_isolation_tree(right, depth-1)) ``` ### GPU Implementation The implementation uses a single-kernel implementation where all trees are built in parallel: ``` ┌─────────────────────────────────────────────────────────────┐ │ GPU Kernel Launch │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ │ Block 0 │ │ Block 1 │ │ Block 2 │ ... │Block N-1│ │ │ │ Tree 0 │ │ Tree 1 │ │ Tree 2 │ │Tree N-1 │ │ │ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ └─────────────────────────────────────────────────────────────┘ ``` **Key design decisions:** 1. **One block per tree**: Each CUDA block builds one complete isolation tree independently 2. **Subsample gathering**: Random indices generated, then data gathered into contiguous row-major buffer for cache-friendly tree building 3. **Iterative DFS**: Tree construction uses explicit stack in shared memory 4. **Random splits**: Feature and threshold selected via cuRAND per node **Kernel structure:** ```cpp __global__ void build_isolation_trees_kernel(...) { int tree_id = blockIdx.x; // Phase 1: Generate random sample indices // Phase 2: Gather subsample into contiguous buffer (col-major → row-major) // Phase 3: Build tree iteratively using shared memory stack } ``` ### Treelite / nvForest Inference After fitting, the model compacts the used nodes from the padded GPU tree representation and exports them to Treelite. The exported Treelite model is represented as a regression forest: - Internal nodes store the Isolation Forest split feature and threshold - Leaf nodes store pre-computed path length (`depth + c(n_leaf)`) - The Treelite forest averages tree outputs to produce `E[h(x)]`, the mean path length - cuML applies the Isolation Forest scoring formula outside Treelite: ``` paper_score = 2^(-E[h(x)] / c(max_samples)) sklearn_score = -(paper_score - 0.5) ``` This keeps the exported tree structure faithful to the trained model while allowing nvForest to execute the expensive tree traversal path efficiently on GPU. The Python API exposes `as_treelite()` and `as_nvforest()`, and internal nvForest-backed scoring matches the existing cuML scoring path within ~3e-7 on the benchmark below. ### Quick Complexity Analysis | Operation | CPU (sklearn) | GPU (cuML) | |-----------|---------------|------------| | **Training** | O(n_trees × max_samples × log(max_samples) × n_features) | O(max_samples × log(max_samples) × n_features) - parallelized across trees | **Memory complexity:** - Tree storage: O(n_trees × 2^max_depth) nodes - Subsample buffer: O(n_trees × max_samples × n_features) - temporary during training - Typical: 100 trees × 256 samples × 100 features × 4 bytes ≈ 10 MB temporary --- ### Why Isolation Forest Training is Memory-Bound **Arithmetic Intensity Analysis** **Step 1: Subsample Gathering** ``` Per tree: - Load 256 random rows from dataset (column-major) - Each row: D features × 4 bytes Memory: 256 × D × 4 bytes Compute: 0 FLOPs (just data movement) Arithmetic Intensity = 0 FLOP/byte ← Pure memory operation ``` **Step 2: Tree Building** ``` Per tree (256 samples, max_depth ≈ 8): - ~255 nodes to potentially create - Per node: - Find min/max of one feature: 2 comparisons × samples_in_node - Generate random threshold: ~3 FLOPs - Partition samples: ~samples_in_node comparisons + swaps Total FLOPs per tree: ~3000-5000 FLOPs Data already in shared memory: 256 × D × 4 bytes loaded once Arithmetic Intensity ≈ 4000 / (256 × 100 × 4) ≈ 0.04 FLOP/byte ``` #### Comparison | Algorithm | Arithmetic Intensity | Bound | |-----------|---------------------|-------| | Matrix Multiply (GEMM) | ~100-1000 FLOP/byte | Compute | | Gradient Boosting Training | ~1-10 FLOP/byte | Mixed | | **Isolation Forest Training** | **~0.04 FLOP/byte** | **Memory** | | Memory Copy | 0 FLOP/byte | Memory | #### Why So Memory-Bound? 1. **Subsampling is the dominant cost**: The algorithm only uses 256 samples per tree (configurable) regardless of dataset size. Gathering these 256 scattered rows is pure memory bandwidth and latency. 2. **Minimal computation per sample**: Unlike gradient boosting (histogram binning, gradient computation), IF just does: - 1 random feature selection - 1 min/max scan - 1 random threshold - 1 partition 3. **Tree building is fast**: With only 256 samples and depth ~8, building one tree is trivial compute (~4000 FLOPs = microseconds on any GPU). ## Evaluation ### Detection Quality The first round of tests show that GPU implementation produces **equivalent detection quality** to sklearn on synthetic datasets mimicking real-world anomaly detection scenarios: #### Credit Card Fraud-like (285K samples, 30 features, 0.17% anomalies) <img width="1435" height="395" alt="Screenshot 2026-02-02 at 14 08 55" src="https://github.com/user-attachments/assets/3c79b040-9609-4fd8-9461-7b32e1c26f8e" /> #### Network Intrusion-like (2.8M samples, 78 features, 20% anomalies) <img width="1432" height="392" alt="Screenshot 2026-02-02 at 14 09 15" src="https://github.com/user-attachments/assets/6812e147-2af0-4a88-9030-07f4a8d952de" /> #### High-Dimensional (1M samples, 200 features, 2% anomalies) <img width="1434" height="394" alt="Screenshot 2026-02-02 at 14 08 59" src="https://github.com/user-attachments/assets/577e79b0-a149-4b13-8c9a-e0478edb9809" /> **Key observations from figures:** - **Score Correlation**: High correlation (>0.9) between cuML and sklearn scores, showing similar ranking behavior - **Score Distributions**: Different absolute ranges but same separation between normal/anomaly classes - **ROC Curves**: Nearly identical curves with matching AUC values (1.0000 on these well-separated synthetic datasets) > **Note**: Exact score matching is not expected due to different random number generators and tree construction order. What matters is equivalent **ranking quality**, which ROC-AUC measures. --- ## Microbenchmark Performance Results > **Disclaimer**: All benchmarks below are synthetic microbenchmarks on a single hardware configuration. Absolute times and speedup ratios will vary with different GPUs, CPUs, memory bandwidth, dataset characteristics, and system load. They are intended to illustrate scaling trends, not to guarantee production performance. > > **Hardware**: NVIDIA RTX Pro 6000 Blackwell (95 GB GDDR7, ~1.8 TB/s) — AMD Ryzen Threadripper PRO 7965WX 24-Cores > **Benchmark script**: [`python/cuml/tests/scripts/if_bench.py`](python/cuml/tests/scripts/if_bench.py) The benchmark compares four input paths: - **cuML (F,GPU)** — column-major (Fortran-order) GPU data, the optimal path with zero internal copy - **cuML (C,GPU)** — row-major (C-order) GPU data, measures relayout overhead - **cuML (CPU)** — NumPy CPU data passed to cuML, measures host→device transfer + relayout + fit - **sklearn** — scikit-learn `IsolationForest` on CPU (`n_jobs=-1`) ### Training + Treelite Export Scaling (up to 10 GB) The current `fit()` path includes GPU tree construction **and** Treelite model construction/serialization. On the optimal F-order GPU input path, training + Treelite export remains in the low-millisecond range up to 10 GB. | Dataset Size | Rows | cuML (F,GPU) | cuML (C,GPU) | cuML (CPU) | sklearn | F-GPU speedup vs sklearn | |--------------|------|--------------|--------------|------------|---------|---------------------------| | 4 MB | 10,000 | 0.010s | 0.008s | 0.008s | 0.109s | 10.9x | | 10 MB | 25,000 | 0.006s | 0.002s | 0.014s | 0.121s | 20.2x | | 20 MB | 50,000 | 0.006s | 0.005s | 0.022s | 0.149s | 24.8x | | 40 MB | 100,000 | 0.005s | 0.006s | 0.041s | 0.158s | 31.6x | | 100 MB | 250,000 | 0.005s | 0.006s | 0.068s | 0.186s | 37.2x | | 200 MB | 500,000 | 0.005s | 0.006s | 0.131s | 0.199s | 39.8x | | 400 MB | 1,000,000 | 0.007s | 0.007s | 0.246s | 0.252s | 36.0x | | 1 GB | 2,500,000 | 0.003s | 0.009s | 0.467s | 0.460s | 153.3x | | 2 GB | 5,000,000 | 0.007s | 0.024s | 0.925s | 0.811s | 115.9x | | 4 GB | 10,000,000 | 0.005s | 0.037s | 1.839s | 1.532s | 306.4x | | 10 GB | 25,000,000 | 0.005s | 0.076s | 6.878s | 24.787s | **4,957.4x** | #### Training Throughput | Dataset Size | cuML (F,GPU) | cuML (C,GPU) | cuML (CPU) | sklearn | |--------------|--------------|--------------|------------|---------| | 4 MB | 1,004,531 rows/s | 1,205,644 rows/s | 1,257,106 rows/s | 91,714 rows/s | | 10 MB | 4,516,051 rows/s | 11,343,805 rows/s | 1,817,610 rows/s | 206,962 rows/s | | 20 MB | 8,399,512 rows/s | 9,169,036 rows/s | 2,232,279 rows/s | 336,664 rows/s | | 40 MB | 21,943,939 rows/s | 15,587,898 rows/s | 2,466,664 rows/s | 634,382 rows/s | | 100 MB | 46,227,400 rows/s | 41,397,260 rows/s | 3,686,573 rows/s | 1,341,572 rows/s | | 200 MB | 92,847,528 rows/s | 83,160,540 rows/s | 3,802,457 rows/s | 2,517,556 rows/s | | 400 MB | 151,079,311 rows/s | 141,707,932 rows/s | 4,058,353 rows/s | 3,974,036 rows/s | | 1 GB | 837,441,823 rows/s | 268,914,100 rows/s | 5,358,897 rows/s | 5,436,266 rows/s | | 2 GB | 742,197,978 rows/s | 205,560,916 rows/s | 5,407,163 rows/s | 6,165,504 rows/s | | 4 GB | 1,888,910,884 rows/s | 270,300,383 rows/s | 5,437,180 rows/s | 6,527,926 rows/s | | 10 GB | **4,962,118,198 rows/s** | 328,928,870 rows/s | 3,634,768 rows/s | 1,008,580 rows/s | ### Dataset Width Scaling (Fixed 4 GB) This benchmark holds total input size fixed at 4 GB while varying feature count. As columns increase, row count decreases, so this measures sensitivity to dataset shape at a constant memory footprint. The `fit()` timing includes Treelite model construction/serialization. | Columns | Rows | cuML (F,GPU) | cuML (C,GPU) | cuML (CPU) | sklearn | F-GPU speedup vs sklearn | |---------|------|--------------|--------------|------------|---------|---------------------------| | 8 | 125,000,000 | 0.007s | 0.012s | 1.972s | 37.383s | **5,340.4x** | | 16 | 62,500,000 | 0.026s | 0.012s | 0.713s | 8.663s | 333.2x | | 32 | 31,250,000 | 0.009s | 0.029s | 0.318s | 4.522s | 502.4x | | 64 | 15,625,000 | 0.005s | 0.032s | 0.323s | 2.353s | 470.6x | | 128 | 7,812,500 | 0.006s | 0.034s | 0.325s | 1.223s | 203.8x | | 256 | 3,906,250 | 0.003s | 0.035s | 0.360s | 0.617s | 205.7x | | 512 | 1,953,125 | 0.006s | 0.027s | 0.322s | 0.379s | 63.2x | | 1024 | 976,562 | 0.005s | 0.025s | 0.316s | 0.273s | 54.6x | | 2048 | 488,281 | 0.013s | 0.039s | 0.313s | 0.196s | 15.1x | ### Treelite / nvForest Inference Scaling (up to 10 GB) The inference benchmark separates Treelite deserialization (`as_treelite()`), nvForest model loading (`as_nvforest()`), current cuML C++ scoring, nvForest-backed scoring, and sklearn scoring. The `Fit` column also includes Treelite serialization because export is performed during `fit()`. | Dataset Size | Rows | Fit | Treelite | nvForest load | cuML score | nvForest score | sklearn score | Max score diff | |--------------|------|-----|----------|---------------|------------|----------------|---------------|----------------| | 4 MB | 10,000 | 0.006s | 0.000s | 0.012s | 0.002s | 0.001s | 0.029s | 2.68e-7 | | 10 MB | 25,000 | 0.005s | 0.002s | 0.003s | 0.001s | 0.000s | 0.068s | 2.68e-7 | | 20 MB | 50,000 | 0.005s | 0.000s | 0.002s | 0.001s | 0.000s | 0.135s | 2.38e-7 | | 40 MB | 100,000 | 0.003s | 0.001s | 0.002s | 0.001s | 0.000s | 0.272s | 2.68e-7 | | 100 MB | 250,000 | 0.003s | 0.000s | 0.002s | 0.002s | 0.001s | 0.689s | 2.38e-7 | | 200 MB | 500,000 | 0.003s | 0.000s | 0.002s | 0.004s | 0.002s | 1.403s | 2.68e-7 | | 400 MB | 1,000,000 | 0.002s | 0.000s | 0.002s | 0.006s | 0.002s | 2.746s | 2.68e-7 | | 1 GB | 2,500,000 | 0.002s | 0.000s | 0.002s | 0.015s | 0.004s | 6.965s | 2.98e-7 | | 2 GB | 5,000,000 | 0.005s | 0.000s | 0.003s | 0.029s | 0.007s | 13.806s | 2.68e-7 | | 4 GB | 10,000,000 | 0.005s | 0.000s | 0.002s | 0.057s | 0.014s | 26.730s | 3.28e-7 | | 10 GB | 25,000,000 | 0.006s | 0.000s | 0.002s | 0.141s | 0.033s | 68.647s | 3.28e-7 | #### Inference Throughput | Dataset Size | cuML score | nvForest score | sklearn score | |--------------|------------|----------------|---------------| | 4 MB | 5,231,286 rows/s | 11,608,254 rows/s | 340,173 rows/s | | 10 MB | 44,249,354 rows/s | 94,856,501 rows/s | 367,111 rows/s | | 20 MB | 75,199,278 rows/s | 163,838,272 rows/s | 369,359 rows/s | | 40 MB | 126,189,016 rows/s | 224,780,221 rows/s | 367,692 rows/s | | 100 MB | 124,374,149 rows/s | 365,230,095 rows/s | 362,625 rows/s | | 200 MB | 142,071,043 rows/s | 327,824,784 rows/s | 356,404 rows/s | | 400 MB | 159,708,640 rows/s | 466,135,707 rows/s | 364,164 rows/s | | 1 GB | 168,557,920 rows/s | 614,880,905 rows/s | 358,960 rows/s | | 2 GB | 171,312,449 rows/s | 680,810,180 rows/s | 362,174 rows/s | | 4 GB | 175,256,953 rows/s | 736,178,615 rows/s | 374,106 rows/s | | 10 GB | 177,387,928 rows/s | **760,045,583 rows/s** | 364,183 rows/s | ### Performance Analysis **Key Points:** 1. **Training + export remains low-latency**: On the RTX Pro 6000 Blackwell, cuML (F,GPU) trains and serializes the Treelite model in the low-millisecond range up to 10 GB (25M rows × 100 features). 2. **Memory layout matters**: The F-order GPU path is the optimal path. C-order GPU data pays re-layout overhead, and CPU input additionally pays host→device transfer cost. 3. **Dataset shape matters at fixed memory footprint**: On a fixed 4 GB input, cuML remains in the low-millisecond range from 8 to 2048 columns. Speedup is largest for tall/narrow data (**~5,340x** at 8 columns, 125M rows) and narrows as row count decreases for very wide data (**~15x** at 2048 columns, 488K rows). Sklearn performance scales with number of rows. 4. **nvForest inference is substantially faster than the current C++ scoring path**: At 10 GB, nvForest-backed scoring takes **0.033s** versus **0.141s** for current cuML scoring, a **~4.3x** speedup while matching scores within **3.28e-7** max absolute difference. 5. **Inference vs sklearn is orders of magnitude faster**: At 10 GB, nvForest scoring takes **0.033s** versus sklearn's **68.647s**, a **~2,080x** inference speedup. Current cuML scoring is also **~487x** faster than sklearn at this size. This wasn't a comprehensive benchmark of nvForest, so take that context in mind. 6. **Throughput scales with dataset size**: Training and inference throughput improve as problem sizes become large enough to amortize fixed overheads. nvForest inference reaches **760M rows/s** at 10 GB. #### Dataset Size Limits The implementation does not have a small algorithmic row-count limit in the training path: `n_rows` is represented as `size_t`, and tree construction samples only `max_samples` rows per tree. In practice, the maximum supported dataset size is determined by GPU memory and the input path. F-order GPU input is the most memory-efficient path, while C-order GPU or CPU input require additional full-matrix layout conversion and/or host-to-device transfer buffers. This PR has benchmarked synthetic datasets up to **25M rows × 100 features** (~10 GB, `float32`) on the RTX Pro 6000 Blackwell system. Larger datasets and less favorable input paths need to be validated in follow-up PRs, including stress tests near device-memory limits and tests that cover `cuml.accel` CPU-input ingestion behavior. ### Results on Other Hardware, with C-major data H100, L40, DGX Spark benchmarks: #### Training Speedup vs sklearn (varying dataset size) | Dataset Size | Rows | H100/Xeon 112c/speedup | L40/AMD 64c/speedup | DGX Spark GPU/CPU/Speedup | |--------------|------|------------------|---------------|-----------| | 4 MB | 10K | 0.002s / 0.523s / **333.6x** | 0.002s / 0.387s / **200.9x** | 0.010s / 0.132s / **13.4x** | | 10 MB | 25K | 0.014s / 0.615s / **45.1x** | 0.002s / 0.395s / **175.1x** | 0.011s / 0.136s / **12.8x** | | 20 MB | 50K | 0.003s / 0.681s / **234.0x** | 0.002s / 0.482s / **214.4x** | 0.012s / 0.160s / **13.3x** | | 40 MB | 100K | 0.003s / 0.745s / **241.2x** | 0.003s / 0.508s / **196.3x** | 0.013s / 0.171s / **13.0x** | | 100 MB | 250K | 0.003s / 0.794s / **256.4x** | 0.003s / 0.533s / **200.5x** | 0.017s / 0.194s / **11.2x** | | 200 MB | 500K | 0.003s / 0.810s / **279.6x** | 0.004s / 0.566s / **146.7x** | 0.021s / 0.217s / **10.4x** | | 400 MB | 1M | 0.004s / 0.862s / **234.5x** | 0.006s / 0.732s / **132.3x** | 0.031s / 0.272s / **8.7x** | | 1 GB | 2.5M | 0.007s / 1.307s / **178.2x** | 0.011s / 1.299s / **120.2x** | 0.071s / 0.404s / **5.7x** | | 2 GB | 5M | 0.015s / 2.192s / **147.3x** | 0.019s / 2.068s / **107.6x** | 0.136s / 0.630s / **4.6x** | | 4 GB | 10M | 0.031s / 3.752s / **122.4x** | 0.037s / 3.486s / **94.9x** | 0.259s / 0.994s / **3.8x** | | 10 GB | 25M | 0.069s / 6.753s / **97.6x** | 0.087s / 7.391s / **85.4x** | 0.659s / 2.166s / **3.3x** | *Format: cuML time / sklearn time / **Speedup*** #### Column Scaling (Fixed 4GB dataset, 100 trees) | Columns | Rows | H100 | L40 | DGX Spark | |---------|------|------|-----|-----------| | 8 | 125M | 0.010s | 0.017s / **1186x** | 0.201s / **47x** | | 16 | 62.5M | 0.011s | 0.016s / **789x** | 0.204s / **25x** | | 32 | 31.25M | 0.055s | 0.030s / **289x** | 0.249s / **11x** | | 64 | 15.6M | 0.029s | 0.034s / **148x** | 0.256s / **6x** | | 128 | 7.8M | 0.030s | 0.037s / **62x** | 0.263s / **3x** | | 256 | 3.9M | 0.029s | 0.039s / **38x** | 0.266s / **2x** | | 512 | 1.95M | 0.026s | 0.041s / **23x** | 0.274s / **1.2x** | | 1024 | 976K | 0.024s | 0.044s / **17x** | 0.287s / **0.9x** | | 2048 | 488K | 0.026s | 0.047s / **12x** | 0.347s / **0.7x** | #### Tree Scaling (Fixed 4GB dataset, 100 columns) | Trees | H100 | L40 | DGX Spark | |-------|------|-----|-----------| | 10 | 0.029s | 0.035s / **12x** | 0.259s / 0.6x | | 25 | 0.034s | 0.035s / **26x** | 0.257s / 1.2x | | 50 | 0.049s | 0.035s / **51x** | 0.256s / 2.1x | | 100 | 0.056s | 0.035s / **100x** | 0.261s / 3.9x | | 200 | 0.039s | 0.036s / **192x** | 0.260s / 7.6x | | 500 | 0.029s | 0.038s / **461x** | 0.273s / 17.8x | | 1000 | 0.042s | 0.041s / **846x** | 0.280s / 34.8x | #### Memory-Bandwidth Analysis The ~10x performance gap between discrete GPUs (H100/L40 with HBM) and integrated GB10 (LPDDR5X) confirms the algorithm is heavily memory-bandwidth bound: | System | Memory Bandwidth | Training Time (4GB) | Ratio | |--------|------------------|---------------------|-------| | H100 | 3,350 GB/s | 0.031s | 1.0x | | L40 | 864 GB/s | 0.037s | 1.2x | | GB10 | ~270 GB/s | 0.259s | 8.4x | --- ## Files Changed - `cpp/include/cuml/ensemble/isolation_forest.hpp` - Public C++ API - `cpp/src/isolation_forest/isolation_forest.cuh` - Core implementation - `cpp/src/isolation_forest/isolation_tree_builder.cuh` - CUDA kernel - `cpp/src/isolation_forest/isolation_forest.cu` - Template instantiations and Treelite export builder - `cpp/tests/sg/isolation_forest_test.cu` - C++ unit tests including Treelite export metadata coverage - `python/cuml/cuml/ensemble/isolation_forest.pyx` - Python bindings, Treelite export, and nvForest-backed scoring helper - `python/cuml/tests/test_isolation_forest.py` - Python tests including Treelite serialization and nvForest score parity - `python/cuml/tests/scripts/if_bench.py` - Training, export, and inference benchmark script Authors: - Dante Gama Dessavre (https://github.com/dantegd) - Simon Adorf (https://github.com/csadorf) Approvers: - Victor Lafargue (https://github.com/viclafargue) - Simon Adorf (https://github.com/csadorf) - Philip Hyunsu Cho (https://github.com/chyunsu3) URL: #8226
Contributor
Author
|
SUCCESS - forward-merge complete. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Forward-merge triggered by push to release/26.08 that creates a PR to keep main up-to-date. If this PR is unable to be immediately merged due to conflicts, it will remain open for the team to manually merge. See forward-merger docs for more info.