Skip to content

dd training callbacks, feature validation, and update XGBoost API integration' or 'Implement callback mechanism and refactor gradient/hessian passing with array interfaces - #12

Open
agene0001 wants to merge 40 commits into
marcomq:masterfrom
agene0001:master

Conversation

@agene0001

@agene0001 agene0001 commented Jan 19, 2026

Copy link
Copy Markdown

Fixed deprecated function warnings and added TODO features. Also bumped up version numbers and edition

Summary by CodeRabbit

  • New Features

    • Per-round training callbacks with early-stop capability.
    • New distinct BinaryError metric for default-threshold binary classification.
  • Added

    • Faster, modern CSR/CSC and binary data loading via a new array-interface path.
    • New benchmarks, verification guides, and example benchmark tooling; benchmark results included.
    • Package/edition version bump and updated examples.
  • Fixed

    • Validation to detect mismatched feature names vs. column counts.
  • Documentation

    • Updated changelog and detailed benchmark documentation.

Review Change Stack

@coderabbitai

coderabbitai Bot commented Jan 19, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds iteration-aware custom-objective training, per-round callbacks with early-stop, JSON array-interface marshaling for gradients/hessians, feature-name count validation, and tests updated to use the new BinaryError metric.

Changes

Booster & Training

Layer / File(s) Summary
Array-interface helpers & JSON marshaling
src/booster.rs
Adds NumPy-style JSON array-interface helper for f32 buffers; marshals gradients/hessians into JSON interfaces passed to XGBoosterTrainOneIter.
Iteration-aware update & train loop
src/booster.rs
update_custom now accepts iteration; training loop threads current iteration into custom objectives and calls TrainOneIter with iteration.
Per-round callbacks & early stop
src/booster.rs, src/parameters/mod.rs
Collects evaluation results into CallbackEnv each round and invokes registered TrainingCallbacks which can stop training by returning false.
Feature-name validation
src/booster.rs
Adds private validate_features that errors when booster feature-name count and DMatrix column count both known and mismatch.
Tests
src/booster.rs (tests)
Unit tests updated to use BinaryError instead of BinaryErrorRate(0.5).

DMatrix & IO

Layer / File(s) Summary
(Not covered by these ranges)
src/dmatrix.rs, benches/examples
CSR/CSC and URI/config loading changes, 64-bit index APIs, and benchmarks/examples are present in the PR but not in these booster-focused ranges.

Sequence Diagram(s)

sequenceDiagram
    participant Trainer as "Trainer"
    participant Booster as "Booster"
    participant Obj as "Custom Objective"
    participant Eval as "Evaluator"
    participant Callback as "Training Callback"

    Trainer->>Booster: train(dtrain, params with callbacks)
    loop per iteration
        Booster->>Obj: compute objective(dtrain, iteration)
        Obj-->>Booster: gradients, hessians
        Booster->>Booster: make_array_interface(grad, hess)
        Booster->>Eval: evaluate(dtrain)
        Eval-->>Booster: evaluation_results
        Booster->>Callback: invoke callback(&CallbackEnv)
        alt callback returns false
            Callback-->>Booster: false
            Booster-->>Trainer: stop training early
        else callback returns true
            Callback-->>Booster: true
            Booster->>Booster: XGBoosterTrainOneIter(..., iteration)
        end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐰 I hopped through arrays and rounds today,
JSON bridges carry grad and hess away,
Callbacks whisper "stop" or "go" each turn,
Error split lets metrics rightly learn,
A rabbit cheers the code that hums and plays!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Title check ❓ Inconclusive The pull request title is unclear and appears to be incomplete or corrupted, containing fragmented phrases ('dd training callbacks', 'update XGBoost API integration') that don't form a coherent summary of the main changes. Revise the title to be a single, clear sentence that accurately describes the primary change. For example: 'Implement training callbacks and refactor API integration with XGBoost 3.1.3' or similar.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/booster.rs (1)

233-243: Custom eval results are stored under the wrong key.

dmat_eval_results is documented as dataset → metric, but the custom eval path inserts eval_name as the outer key and dmat_name as the inner key, swapping axes. This produces incorrect output (e.g., custom-train) and makes callback data inconsistent.

🐛 Fix key placement
-                    let eval_results = dmat_eval_results
-                        .entry(eval_name.to_string())
-                        .or_insert_with(IndexMap::new);
-                    eval_results.insert(dmat_name.to_string(), eval_result);
+                    let eval_results = dmat_eval_results
+                        .entry(dmat_name.to_string())
+                        .or_insert_with(IndexMap::new);
+                    eval_results.insert(eval_name.to_string(), eval_result);
src/dmatrix.rs (1)

167-221: Design clarity improvement: Document that CSR/CSC inputs are copied by XGBoost.

The current implementation passes raw pointers to XGBoost's C API, which could appear unsafe without documentation. However, XGBoost copies the data during the FFI call (consistent with from_dense which uses the identical pattern), making the borrowed-slice API safe. Add a documentation comment explicitly stating that input buffers are copied and can be safely dropped after the call, or consider adding a safety note similar to new_with_cached_dmats() in Booster.

🤖 Fix all issues with AI agents
In `@src/dmatrix.rs`:
- Around line 258-265: The code in load_binary currently uses
path.as_ref().to_string_lossy(), which can silently alter non‑UTF8 paths; change
it to require valid UTF‑8 by using path.as_ref().to_str() and return an error if
it returns None (or map to a Result) before building the JSON config; then use
the obtained &str (escape backslashes/quotes as you already do) to create the
JSON config and CString, keeping the rest (config_cstr and
XGDMatrixCreateFromURI call) unchanged — reference symbols: load_binary, path,
escaped_path, config, config_cstr, and XGDMatrixCreateFromURI.
- Around line 12-46: The current make_array_interface_usize produces
platform-dependent typestr and can yield 4-byte types on 32-bit targets, which
breaks XGBoost's expectation of 64-bit CSR/CSC indices; change the API to use
fixed-width u64: replace or overload make_array_interface_usize to accept &[u64]
(or add new make_array_interface_u64) and ensure callers that pass indptr and
indices convert/collect their values to Vec<u64> before calling (update call
sites that currently pass indptr/indices slices), then emit typestr "<u8"
unconditionally so the array interface always encodes 8-byte unsigned indices.
♻️ Duplicate comments (1)
src/dmatrix.rs (1)

409-415: Array-interface lifetime/typing verification (same as above).

🧹 Nitpick comments (3)
Cargo.toml (1)

11-24: Confirm MSRV and prerelease dependency intent.

Edition 2024 (Line 11) raises MSRV and libc 1.0.0-alpha.2 is a pre-release. Please confirm the minimum supported Rust version is documented/CI-updated and that the alpha dependency is intentional; otherwise consider a stable libc version.

src/booster.rs (2)

13-28: Optional: centralize array-interface helper to reduce duplication.

make_array_interface duplicates the f32 helper in src/dmatrix.rs. Consider a shared utility to keep the spec in one place.


516-543: Consider validating features for the standard update path too.

validate_features is only invoked in boost (custom objective). If the goal is to guard all training flows, call it from update or at the start of the train loop.

Comment thread src/dmatrix.rs
Comment thread src/dmatrix.rs
@agene0001 agene0001 changed the title Deprecated functions and TODO fixes dd training callbacks, feature validation, and update XGBoost API integration' or 'Implement callback mechanism and refactor gradient/hessian passing with array interfaces Jan 19, 2026
@agene0001

Copy link
Copy Markdown
Author

Hey @marcomq, I was wondering if you could take a look at this PR as I use it in one of my projects. I don't believe it breaks anything as the tests still pass. Thanks

@marcomq marcomq left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you very much for the PR. It looks good and seems to be a good step forward.

However, it might introduce some breaking changes and might have performance impact as it internally uses now different functions that work with strings. These from_csc / from_dense functions are usually called in the inner loop and as we don't have performance benchmarks yet, I would prefer to not apply these changes silently.

Can you try to create new separate functions for the changed from_csc / from_dense, so it doesn't affect current functionality.

Comment thread src/dmatrix.rs
/// # Errors
///
/// Returns an error if the path contains non-UTF8 characters.
pub fn load_binary<P: AsRef<Path>>(path: P) -> XGBResult<Self> {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a breaking change as it uses another C function that uses urls instead of files. For consistency, we should also use a new function here too. Let's keep the old function as it is and add load_binary_from_uri

Comment thread Cargo.toml
documentation = "https://docs.rs/xgboost_lib"
readme = "README.md"
edition = "2021"
edition = "2024"

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is modern, but might be a breaking change for others. Can we keep it at 2021?

Comment thread Cargo.toml Outdated
log = "0.4"
tempfile = "3.15"
indexmap = "2.7"
libc = "1.0.0-alpha.2"

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't feel good about using an alpha version. Is this really necessary?

Comment thread Cargo.toml
tempfile = "3.15"
indexmap = "2.7"
libc = "1.0.0-alpha.2"
derive_builder = "0.20.2"

@marcomq marcomq Jan 23, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

xgboost is used as a library. Just keep "0.20" / "0.4". Rust will automatically try to select the latest most compatible version.
Only update it if necessary or breaking change.

In your later app, you can specify a more recent version, if you like to.

Comment thread src/dmatrix.rs
let data_cstr = ffi::CString::new(data_interface).unwrap();
let config = ffi::CString::new(r#"{"missing": NaN}"#).unwrap();

xgb_call!(xgboost_sys::XGDMatrixCreateFromCSR(

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally a good change.
But this looks like it may affect performance negatively as we are now allocating new strings for all the input. We must avoid undocumented perforformance regressions. Please create a new function, maybe from_dense_new or similar.

Comment thread src/dmatrix.rs
let data_cstr = ffi::CString::new(data_interface).unwrap();
let config = ffi::CString::new(r#"{"missing": NaN}"#).unwrap();

xgb_call!(xgboost_sys::XGDMatrixCreateFromCSC(

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, this may have a negative performance impact.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/dmatrix.rs (1)

169-202: Breaking API change not addressed per maintainer feedback.

The maintainer previously requested keeping the old function and adding a new one (e.g., from_csr_new) to avoid undocumented performance regressions from the new string allocations. The signature change from &[usize] to &[u64] is also a breaking change for existing callers.

Consider either:

  1. Restoring the old from_csr implementation and adding from_csr_v2 or similar
  2. Adding a deprecation notice and migration guide if replacing is intentional
🤖 Fix all issues with AI agents
In `@benches/dmatrix_benchmark.rs`:
- Line 240: Update the printed threshold text so it matches the library logic:
change the println call that currently outputs "Threshold: 5000 non-zeros
(single-thread below, multi-thread above)" to state 30000 non-zeros instead;
locate and edit the println! invocation in benches/dmatrix_benchmark.rs (the
line that prints the threshold message) to reflect "Threshold: 30000 non-zeros
(single-thread below, multi-thread above)".
- Around line 122-130: The SINGLE_THREAD_THRESHOLD in from_csr_auto_tuned is set
to 5000 which diverges from the library implementation and bench_table.rs;
update the threshold constant used in from_csr_auto_tuned to 30000 so it matches
DMatrix::from_csr's behavior and the example benchmark; ensure the constant name
SINGLE_THREAD_THRESHOLD remains unchanged and that any logic using it continues
to select single-thread mode for sizes below 30000.
- Around line 306-310: The hardcoded 5000 threshold in the optimal selection
logic (the expression setting optimal using nnz, single_us, multi_us) must be
replaced with a named constant and updated to the correct threshold value;
introduce a const (e.g., OPTIMAL_NNZ_THRESHOLD) near the benchmarking constants
and use it in the condition: if (nnz < OPTIMAL_NNZ_THRESHOLD && single_us <=
multi_us) || (nnz >= OPTIMAL_NNZ_THRESHOLD && multi_us <= single_us) so the
threshold can be changed centrally and set to the updated value required by the
benchmark.
- Around line 329-333: There is a duplicate main when feature "print_table" is
enabled: the standalone fn main calling print_comparison_table conflicts with
the main generated by the Criterion macro; to fix it, gate one of them with cfg
so only one main is compiled — for example add #[cfg(not(feature =
"print_table"))] around the Criterion entry (criterion_main!(benches)) or
alternatively add #[cfg(feature = "print_table")] to the table-printer main;
reference the standalone main that calls print_comparison_table and the
criterion_main!(benches) macro when applying the cfg.

In `@BENCHMARK_VERIFICATION.md`:
- Around line 30-38: The fenced code block showing expected output (the block
starting with "Rows | NNZ | Deprecated (us)" in BENCHMARK_VERIFICATION.md) lacks
a language specifier; update that fenced block to include a language tag such as
"text" or "plaintext" (e.g., change ``` to ```text) so the static analysis
requirement for annotated code blocks is satisfied and the expected-output block
is treated as plain text.

In `@examples/basic/src/main.rs`:
- Around line 68-74: The printed "error=" value is actually computing accuracy
(num_correct as f32 / preds.len() as f32) which is misleading; update the
calculation in the println to use the true error rate by computing 1.0 -
(num_correct as f32 / preds.len() as f32) or (preds.len() - num_correct) as f32
/ preds.len() so the label "error=" matches the value (refer to the variables
num_correct and preds and the println! call).

In `@examples/bench_deprecated.rs`:
- Around line 12-13: The file is missing imports for ptr::null_mut and
Instant::now; add the appropriate imports (for example import std::ptr or
std::ptr::null_mut and std::time::Instant) so calls to ptr::null_mut() and
Instant::now() resolve. Update the top of the file near the existing extern
crate xgboost_sys declaration to include these imports so functions referencing
ptr::null_mut() and Instant::now() compile.
- Around line 54-56: The FFI call to xgboost_sys::XGDMatrixCreateFromCSREx is
incorrectly casting a Vec<u64> pointer to *const u32 (the indices variable)
causing misinterpreted memory; fix by converting or recreating indices as
Vec<u32> (or mapping indices.iter().map(|v| *v as u32).collect() immediately
before the call) and pass that u32 buffer's pointer to XGDMatrixCreateFromCSREx,
ensuring the indptr/indices types match the expected C signature.
- Around line 53-63: The call to XGDMatrixCreateFromCSREx is passing mismatched
pointer types and ignoring its return code: cast indptr.as_ptr() to *const
size_t, ensure indices is a &[u32] (or perform a checked conversion from &[u64]
to Vec<u32] to avoid truncation) and pass indices.as_ptr() as *const u32, keep
data.as_ptr(), indptr.len(), data.len(), num_cols and &mut handle as before,
then capture the returned int (e.g., let ret =
xgboost_sys::XGDMatrixCreateFromCSREx(...)) and check if ret != 0 and handle or
propagate the error (panic or map to Result) instead of ignoring it.

In `@examples/bench_table.rs`:
- Around line 75-86: The call to the FFI function XGDMatrixCreateFromCSR
currently ignores its return code and may leave handle null; update the code to
check the function's return value and verify handle is non-null before returning
or continuing. Specifically, capture the return code from
XGDMatrixCreateFromCSR, handle non-zero/error codes by returning an Err or
panicking with a clear message, and if the call appears to fail ensure you do
not call XGDMatrixFree on a null handle; alternatively return a Result from the
surrounding function so callers can handle failures instead of unconditionally
returning handle.
♻️ Duplicate comments (3)
Cargo.toml (2)

15-19: Prefer semver-compatible ranges over exact version pinning for library dependencies.

Exact version pinning (e.g., libc = "0.2.180") in a library crate can cause dependency resolution conflicts for downstream users. Consider using semver ranges like "0.2" or "0.4" to allow Cargo to select compatible versions, as mentioned in previous review feedback.

♻️ Suggested change
-libc = "0.2.180"
-derive_builder = "0.20.2"
-log = "0.4.29"
-tempfile = "3.24.0"
-indexmap = "2.13.0"
+libc = "0.2"
+derive_builder = "0.20"
+log = "0.4"
+tempfile = "3"
+indexmap = "2"

11-11: Rust edition 2024 may cause compatibility issues for downstream users.

Edition 2024 is very recent and may introduce breaking changes for users who haven't upgraded their toolchains. For a library crate, consider keeping edition = "2021" for broader compatibility, as mentioned in previous review feedback.

src/dmatrix.rs (1)

216-249: Same breaking change concern as from_csr.

This has the same issue - the signature change and new allocations weren't addressed per the maintainer's feedback requesting a new function to preserve backward compatibility.

🧹 Nitpick comments (4)
examples/bench_table.rs (1)

34-59: Code duplication with benches/dmatrix_benchmark.rs.

The generate_sparse_data function is duplicated verbatim across examples/bench_table.rs, benches/dmatrix_benchmark.rs, and examples/bench_deprecated.rs. Consider extracting this to a shared module (e.g., a bench_utils module) to improve maintainability.

examples/bench_deprecated.rs (1)

15-41: Duplicated generate_sparse_data function.

This is the third copy of this function (also in examples/bench_table.rs and benches/dmatrix_benchmark.rs). As noted earlier, consider extracting to a shared utility module.

benches/dmatrix_benchmark.rs (2)

18-36: Consider extracting shared array interface helpers.

These helper functions are duplicated from src/dmatrix.rs. Consider extracting them to a shared internal module to avoid drift and reduce maintenance burden.


171-177: Consider adjusting test cases for 30k threshold.

With the library's threshold of 30000, only "large_50k_nnz" and "xlarge_100k_nnz" would trigger multi-threaded mode in auto-tuned. Consider adding a test case near the 30k boundary (e.g., 25k and 35k nnz) to better validate the threshold behavior.

Comment thread benches/dmatrix_benchmark.rs
Comment thread benches/dmatrix_benchmark.rs
Comment thread benches/dmatrix_benchmark.rs
Comment thread benches/dmatrix_benchmark.rs
Comment thread BENCHMARK_VERIFICATION.md
Comment on lines +30 to +38
You should see output like:
```
Rows | NNZ | Deprecated (us)
---------+----------+----------------
100 | 979 | 56.00
1000 | 10037 | 487.00
2000 | 19849 | 980.00
...
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add language specifier to fenced code blocks.

Per static analysis, fenced code blocks should have a language specified. For expected output, use text or plaintext.

📝 Suggested fix
 You should see output like:
-```
+```text
     Rows |      NNZ | Deprecated (us)
 ---------+----------+----------------
      100 |      979 |          56.00
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
You should see output like:
```
Rows | NNZ | Deprecated (us)
---------+----------+----------------
100 | 979 | 56.00
1000 | 10037 | 487.00
2000 | 19849 | 980.00
...
```
You should see output like:
🧰 Tools
🪛 markdownlint-cli2 (0.18.1)

35-35: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
In `@BENCHMARK_VERIFICATION.md` around lines 30 - 38, The fenced code block
showing expected output (the block starting with "Rows | NNZ | Deprecated (us)"
in BENCHMARK_VERIFICATION.md) lacks a language specifier; update that fenced
block to include a language tag such as "text" or "plaintext" (e.g., change ```
to ```text) so the static analysis requirement for annotated code blocks is
satisfied and the expected-output block is treated as plain text.

Comment thread examples/bench_deprecated.rs Outdated
Comment on lines +12 to +13

extern crate xgboost_sys;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Missing required imports.

The file uses ptr::null_mut() and Instant::now() but doesn't import std::ptr or std::time::Instant. This will cause compilation errors.

🐛 Proposed fix
 extern crate xgboost_sys;
+
+use std::ptr;
+use std::time::Instant;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
extern crate xgboost_sys;
extern crate xgboost_sys;
use std::ptr;
use std::time::Instant;
🤖 Prompt for AI Agents
In `@examples/bench_deprecated.rs` around lines 12 - 13, The file is missing
imports for ptr::null_mut and Instant::now; add the appropriate imports (for
example import std::ptr or std::ptr::null_mut and std::time::Instant) so calls
to ptr::null_mut() and Instant::now() resolve. Update the top of the file near
the existing extern crate xgboost_sys declaration to include these imports so
functions referencing ptr::null_mut() and Instant::now() compile.

Comment on lines +53 to +63
unsafe {
xgboost_sys::XGDMatrixCreateFromCSREx(
indptr.as_ptr(),
indices.as_ptr() as *const u32,
data.as_ptr(),
indptr.len(),
data.len(),
num_cols,
&mut handle,
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

rg -n "XGDMatrixCreateFromCSREx" --type rust -A 10

Repository: marcomq/rust-xgboost

Length of output: 3036


🏁 Script executed:

fd -e rs -e toml | xargs rg -l "XGDMatrixCreateFromCSREx" | head -20

Repository: marcomq/rust-xgboost

Length of output: 92


🏁 Script executed:

# Search for xgboost-sys as a dependency
rg "xgboost.sys|xgboost_sys" --type toml -A 5

Repository: marcomq/rust-xgboost

Length of output: 600


🏁 Script executed:

# Search for function definition in bindings or lib files
rg -n "fn XGDMatrixCreateFromCSREx|extern.*XGDMatrixCreateFromCSREx" -A 5

Repository: marcomq/rust-xgboost

Length of output: 46


🏁 Script executed:

# Explore xgboost-sys directory structure
find xgboost-sys -name "*.rs" | head -20

Repository: marcomq/rust-xgboost

Length of output: 107


🏁 Script executed:

# Search specifically in xgboost-sys for the function
rg -n "XGDMatrixCreateFromCSREx" xgboost-sys/ -A 5

Repository: marcomq/rust-xgboost

Length of output: 46


🏁 Script executed:

# Read xgboost-sys lib.rs to understand bindings
cat xgboost-sys/src/lib.rs

Repository: marcomq/rust-xgboost

Length of output: 1057


🏁 Script executed:

# Check build.rs to see how bindings are generated
head -100 xgboost-sys/build.rs

Repository: marcomq/rust-xgboost

Length of output: 4529


🏁 Script executed:

# Find the C header file
find . -name "c_api.h" -o -name "c_api.h*"

Repository: marcomq/rust-xgboost

Length of output: 46


🏁 Script executed:

# Search for XGDMatrixCreateFromCSREx in header files
rg -n "XGDMatrixCreateFromCSREx" --type c --type h -B 2 -A 10

Repository: marcomq/rust-xgboost

Length of output: 212


🏁 Script executed:

# Search for any documentation or comments mentioning parameter types
rg -n "indptr|indices|XGDMatrix" examples/bench_deprecated.rs -B 5 -A 5

Repository: marcomq/rust-xgboost

Length of output: 4411


🏁 Script executed:

# Look for type definitions in the codebase
rg -n "size_t|bindex_t" --type rust

Repository: marcomq/rust-xgboost

Length of output: 46


🏁 Script executed:

# Check how other XGDMatrix functions handle return values
rg -n "XGDMatrix.*unsafe" -A 3 examples/

Repository: marcomq/rust-xgboost

Length of output: 46


🌐 Web query:

XGDMatrixCreateFromCSREx XGBoost C API signature parameters

💡 Result:

int XGDMatrixCreateFromCSREx(
    const size_t*   indptr,
    const unsigned* indices,
    const float*    data,
    size_t          nindptr,
    size_t          nelem,
    size_t          num_col,
    DMatrixHandle*  out
);

[1]

Parameters

  • indptr: CSR row pointer array (length = nindptr). [2]
  • indices: CSR column indices for each non-zero (length = nelem). [2]
  • data: CSR non-zero values (length = nelem). [2]
  • nindptr: number of rows + 1. [2]
  • nelem: number of non-zero elements. [2]
  • num_col: number of columns (if 0, XGBoost will try to infer it). [2]
  • out: output DMatrixHandle (created DMatrix). [2]

Note: XGDMatrixCreateFromCSREx is deprecated since XGBoost 2.0.0; the recommended API is XGDMatrixCreateFromCSR() (array-interface based). [1]


🏁 Script executed:

# Search for any comments or documentation files about the deprecated API
rg -n "CSR|deprecated" --type md -A 5

Repository: marcomq/rust-xgboost

Length of output: 7608


🏁 Script executed:

# Look at the actual function signature from test cases if they exist
rg -n "XGDMatrixCreateFromCSREx|XGDMatrixCreateFromCSR" -B 10 -A 5

Repository: marcomq/rust-xgboost

Length of output: 17944


Fix type mismatches and add error handling for deprecated API call.

The XGDMatrixCreateFromCSREx function expects const size_t* indptr and const unsigned* indices, but the code passes:

  • indptr.as_ptr() where indptr is &[u64] — size_t is platform-dependent (32-bit on some platforms, 64-bit on others)
  • indices.as_ptr() as &[u64] cast to *const u32 — loses data when converting u64 to u32

Additionally, the function returns an error code (int) that is completely ignored. Cast both arrays to the correct pointer types and check the return value:

Example fix
unsafe {
    let ret = xgboost_sys::XGDMatrixCreateFromCSREx(
        indptr.as_ptr() as *const size_t,
        indices.as_ptr() as *const u32,  // or validate no data loss
        data.as_ptr(),
        indptr.len(),
        data.len(),
        num_cols,
        &mut handle,
    );
    if ret != 0 {
        panic!("XGDMatrixCreateFromCSREx failed with error code: {}", ret);
    }
}
🤖 Prompt for AI Agents
In `@examples/bench_deprecated.rs` around lines 53 - 63, The call to
XGDMatrixCreateFromCSREx is passing mismatched pointer types and ignoring its
return code: cast indptr.as_ptr() to *const size_t, ensure indices is a &[u32]
(or perform a checked conversion from &[u64] to Vec<u32] to avoid truncation)
and pass indices.as_ptr() as *const u32, keep data.as_ptr(), indptr.len(),
data.len(), num_cols and &mut handle as before, then capture the returned int
(e.g., let ret = xgboost_sys::XGDMatrixCreateFromCSREx(...)) and check if ret !=
0 and handle or propagate the error (panic or map to Result) instead of ignoring
it.

Comment on lines +54 to +56
xgboost_sys::XGDMatrixCreateFromCSREx(
indptr.as_ptr(),
indices.as_ptr() as *const u32,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Dangerous pointer cast from u64* to u32*.

The indices vector contains u64 values, but line 56 casts the pointer to *const u32. This reinterprets the memory layout incorrectly—each 64-bit index will be read as two 32-bit values, producing garbage data and potential memory access violations.

Either generate indices as Vec<u32> or convert the data before the FFI call.

🐛 Proposed fix - convert indices to u32
 #[cfg(feature = "deprecated_api")]
 fn from_csr_deprecated(
     indptr: &[u64],
     indices: &[u64],
     data: &[f32],
     num_cols: usize,
     num_rows: usize,
 ) -> xgboost_sys::DMatrixHandle {
     let mut handle = ptr::null_mut();
+    
+    // Convert indices to u32 for deprecated API
+    let indices_u32: Vec<u32> = indices.iter().map(|&x| x as u32).collect();

     unsafe {
         xgboost_sys::XGDMatrixCreateFromCSREx(
             indptr.as_ptr(),
-            indices.as_ptr() as *const u32,
+            indices_u32.as_ptr(),
             data.as_ptr(),
             indptr.len(),
             data.len(),
             num_cols,
             &mut handle,
         );
     }
     handle
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
xgboost_sys::XGDMatrixCreateFromCSREx(
indptr.as_ptr(),
indices.as_ptr() as *const u32,
#[cfg(feature = "deprecated_api")]
fn from_csr_deprecated(
indptr: &[u64],
indices: &[u64],
data: &[f32],
num_cols: usize,
num_rows: usize,
) -> xgboost_sys::DMatrixHandle {
let mut handle = ptr::null_mut();
// Convert indices to u32 for deprecated API
let indices_u32: Vec<u32> = indices.iter().map(|&x| x as u32).collect();
unsafe {
xgboost_sys::XGDMatrixCreateFromCSREx(
indptr.as_ptr(),
indices_u32.as_ptr(),
data.as_ptr(),
indptr.len(),
data.len(),
num_cols,
&mut handle,
);
}
handle
}
🤖 Prompt for AI Agents
In `@examples/bench_deprecated.rs` around lines 54 - 56, The FFI call to
xgboost_sys::XGDMatrixCreateFromCSREx is incorrectly casting a Vec<u64> pointer
to *const u32 (the indices variable) causing misinterpreted memory; fix by
converting or recreating indices as Vec<u32> (or mapping indices.iter().map(|v|
*v as u32).collect() immediately before the call) and pass that u32 buffer's
pointer to XGDMatrixCreateFromCSREx, ensuring the indptr/indices types match the
expected C signature.

Comment thread examples/bench_table.rs
Comment on lines +75 to +86
unsafe {
xgboost_sys::XGDMatrixCreateFromCSR(
indptr_json.as_ptr(),
indices_json.as_ptr(),
data_json.as_ptr(),
num_cols as xgboost_sys::bst_ulong,
config.as_ptr(),
&mut handle,
);
}
handle
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing error handling for XGBoost FFI calls.

The return value of XGDMatrixCreateFromCSR is not checked. If the call fails, handle remains null, which could cause undefined behavior when passed to XGDMatrixFree. Consider checking the return code or at least asserting the handle is non-null after creation.

🛠️ Suggested fix
     unsafe {
-        xgboost_sys::XGDMatrixCreateFromCSR(
+        let ret = xgboost_sys::XGDMatrixCreateFromCSR(
             indptr_json.as_ptr(),
             indices_json.as_ptr(),
             data_json.as_ptr(),
             num_cols as xgboost_sys::bst_ulong,
             config.as_ptr(),
             &mut handle,
         );
+        assert!(ret == 0 && !handle.is_null(), "DMatrix creation failed");
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
unsafe {
xgboost_sys::XGDMatrixCreateFromCSR(
indptr_json.as_ptr(),
indices_json.as_ptr(),
data_json.as_ptr(),
num_cols as xgboost_sys::bst_ulong,
config.as_ptr(),
&mut handle,
);
}
handle
}
unsafe {
let ret = xgboost_sys::XGDMatrixCreateFromCSR(
indptr_json.as_ptr(),
indices_json.as_ptr(),
data_json.as_ptr(),
num_cols as xgboost_sys::bst_ulong,
config.as_ptr(),
&mut handle,
);
assert!(ret == 0 && !handle.is_null(), "DMatrix creation failed");
}
handle
}
🤖 Prompt for AI Agents
In `@examples/bench_table.rs` around lines 75 - 86, The call to the FFI function
XGDMatrixCreateFromCSR currently ignores its return code and may leave handle
null; update the code to check the function's return value and verify handle is
non-null before returning or continuing. Specifically, capture the return code
from XGDMatrixCreateFromCSR, handle non-zero/error codes by returning an Err or
panicking with a clear message, and if the call appears to fail ensure you do
not call XGDMatrixFree on a null handle; alternatively return a Result from the
surrounding function so callers can handle failures instead of unconditionally
returning handle.

Comment thread src/dmatrix.rs
Comment on lines 286 to 301
pub fn load_binary<P: AsRef<Path>>(path: P) -> XGBResult<Self> {
debug!("Loading DMatrix from: {}", path.as_ref().display());
let mut handle = ptr::null_mut();
let fname = crate::path_to_c_str(path);
xgb_call!(xgboost_sys::XGDMatrixCreateFromFile(fname.as_ptr(), 1, &mut handle)).unwrap();
// Use XGDMatrixCreateFromURI with a JSON config specifying the URI
// Binary format is auto-detected, no format parameter needed
let path_str = path
.as_ref()
.to_str()
.ok_or_else(|| XGBError::new("Path contains non-UTF8 characters"))?;
// Escape backslashes and quotes for valid JSON
let escaped_path = path_str.replace('\\', "\\\\").replace('"', "\\\"");
let config = format!(r#"{{"uri": "{}", "silent": 1}}"#, escaped_path);
let config_cstr = ffi::CString::new(config).unwrap();
xgb_call!(xgboost_sys::XGDMatrixCreateFromURI(config_cstr.as_ptr(), &mut handle))?;
DMatrix::new(handle)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Function renamed per maintainer request not implemented.

The maintainer requested keeping the old load_binary function and adding a new load_binary_from_uri function since this uses a different C function (URI-based). The current implementation replaces the old behavior, which is a breaking change.

Update dev-dependency to the latest patch release of criterion to
include recent performance improvements and bug fixes.
XGBoost requires the gradient and hessian to be declared as a 2D [num_row, n_targets] array for multi-target boosters (e.g., distributional models). This patch updates make_array_interface to emit the correct shape when n_targets > 1, inferred from the gradient buffer length relative to the number of rows.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/booster.rs (2)

244-253: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Store custom evals under the dataset key.

dmat_eval_results is dmat_name -> eval_name -> score everywhere else, but this block inserts custom metrics as custom -> dmat_name -> score. That makes callback data inconsistent and prints custom-train instead of train-custom.

💡 Proposed fix
                 if let Some(eval_fn) = params.custom_evaluation_fn {
                     let eval_name = "custom";
                     for (dmat, dmat_name) in eval_sets {
                         let margin = bst.predict_margin(dmat)?;
                         let eval_result = eval_fn(&margin, dmat);
                         let eval_results = dmat_eval_results
-                            .entry(eval_name.to_string())
+                            .entry(dmat_name.to_string())
                             .or_insert_with(IndexMap::new);
-                        eval_results.insert(dmat_name.to_string(), eval_result);
+                        eval_results.insert(eval_name.to_string(), eval_result);
                     }
                 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/booster.rs` around lines 244 - 253, The custom evaluation block currently
inserts results under the evaluation-name key ("custom") causing the map shape
to be eval_name -> dmat_name -> score; change it to store under the dataset key
so the shape matches the rest (dmat_name -> eval_name -> score): inside the loop
over eval_sets (where params.custom_evaluation_fn, eval_sets,
bst.predict_margin, eval_result, dmat_name and dmat_eval_results are used)
replace the entry lookup that uses eval_name as the top-level key with one that
uses dmat_name.to_string() and then insert eval_name.to_string() -> eval_result
into that inner IndexMap so callbacks and prints become dataset-first (e.g.,
"train-custom" instead of "custom-train").

328-330: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use raw margins for the custom objective callback.

XGBoost custom objectives are defined over raw predictions / leaf weights, not transformed outputs. The predict() method applies link function transformations, producing incorrect gradients and Hessians for objectives with a non-identity link. predict_margin() returns raw margins and is the correct source for objective_fn.

💡 Proposed fix
-        let pred = self.predict(dtrain)?;
-        let (gradient, hessian) = objective_fn(&pred.to_vec(), dtrain);
+        let pred = self.predict_margin(dtrain)?;
+        let (gradient, hessian) = objective_fn(&pred, dtrain);
         self.boost(dtrain, iteration, &gradient, &hessian)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/booster.rs` around lines 328 - 330, The code is using transformed
predictions from self.predict() to compute gradients/hessians for objective_fn,
but custom objectives must use raw margins; replace the call to
self.predict(...) with self.predict_margin(...) so objective_fn receives raw
predictions (margins) before boost is called (keep passing the resulting Vec to
objective_fn and then call self.boost(dtrain, iteration, &gradient, &hessian) as
before).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/booster.rs`:
- Around line 244-253: The custom evaluation block currently inserts results
under the evaluation-name key ("custom") causing the map shape to be eval_name
-> dmat_name -> score; change it to store under the dataset key so the shape
matches the rest (dmat_name -> eval_name -> score): inside the loop over
eval_sets (where params.custom_evaluation_fn, eval_sets, bst.predict_margin,
eval_result, dmat_name and dmat_eval_results are used) replace the entry lookup
that uses eval_name as the top-level key with one that uses
dmat_name.to_string() and then insert eval_name.to_string() -> eval_result into
that inner IndexMap so callbacks and prints become dataset-first (e.g.,
"train-custom" instead of "custom-train").
- Around line 328-330: The code is using transformed predictions from
self.predict() to compute gradients/hessians for objective_fn, but custom
objectives must use raw margins; replace the call to self.predict(...) with
self.predict_margin(...) so objective_fn receives raw predictions (margins)
before boost is called (keep passing the resulting Vec to objective_fn and then
call self.boost(dtrain, iteration, &gradient, &hessian) as before).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 14d1c931-7390-4ba8-ab4a-274132a15916

📥 Commits

Reviewing files that changed from the base of the PR and between c732339 and 5f7d58e.

📒 Files selected for processing (1)
  • src/booster.rs

agene0001 and others added 16 commits May 30, 2026 03:10
- update_custom: pass predictions by reference instead of redundantly
  cloning the already-owned Vec on every boosting round
- set_feature_info: keep CStrings alive for the FFI call instead of
  leaking them via into_raw() with no matching from_raw()
- validate_features: count feature names via a new num_feature_names()
  helper that reads only the count, instead of allocating an owned
  String for every name each iteration

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The previous commit recorded the file as 755 due to executable-bit noise
from the external-drive filesystem; restore it to a normal source mode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- libc 0.2.180 -> 0.2.186
- log 0.4.29 -> 0.4.30
- tempfile 3.24.0 -> 3.27.0
- indexmap 2.13.0 -> 2.14.0

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…h, zero-alloc FFI strings

- Add predict_from_dense/predict_from_csr for inplace prediction (no DMatrix round-trip): 1.8–3× faster for serving workloads
- Add from_dense_quantile/from_csr_quantile (QuantileDMatrix via callback API): ~4× lower memory for hist tree method
- Hybrid dispatch in DMatrix::from_dense: XGDMatrixCreateFromMat_omp above 50k elements, ~5.5× faster than original at 2.5M elements
- Replace PredictOption bitmask with static CStr predict_config constants (zero-alloc hot path)
- Replace KEY_* &str statics with &CStr literals in dmatrix.rs (eliminates per-call CString::new)
- Add predict_borrowed/predict_margin_borrowed returning borrowed slices; wire into custom-obj and eval loops (no copy per round)
- Add reset(), serialize_to_buffer(), unserialize_from_buffer() for serving/checkpointing
- Coalesce per-round stdout into single println! via format buffer
- Add predict_benchmark.rs bench suite; extend dmatrix_benchmark.rs with Mat_omp and array-interface groups
- 34 lib tests + 6 doctests passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…eInfo, document nthread tuning

Passing a cached proxy to XGBoosterPredictFromDense/FromCSR instead of null
avoids XGBoost allocating an internal DMatrixProxy per call (~18% of
single-row latency on a single-thread booster; 6.9 -> 5.5 us measured on
xgboost 3.0.0). Quantile-matrix labels now go through
XGDMatrixSetInfoFromInterface, removing the per-construction deprecation
warning. Small-batch serving latency is documented as OpenMP-dispatch-bound
(linear in nthread; pin nthread=1 below ~1000 rows).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… metadata to fork

- Submodule bumped to dmlc/xgboost v3.2.0; previous setup ran 3.1.3 bindgen
  headers against downloaded 3.0.0 binaries (silent version skew). Measured
  vs the old prebuilt: batch predict -65%, training rounds -55%.
- local_build is the default feature: cmake Release (-O3, was RelWithDebInfo)
  with ninja when available, so headers and runtime cannot disagree.
- use_prebuilt_xgb now downloads from this fork's release assets (built by
  the new release-libs.yml workflow on all four targets) and falls back to
  the legacy 3.0.0 binaries with a warning until a release is published.
  web_copy now fails on HTTP errors instead of writing error pages to disk.
- Crate metadata, README, and CI point at agene0001/rust-xgboost; crate
  versions bumped to 3.2.0; committed 3.0.0 binaries removed from the repo.
- Windows CI stays on the prebuilt path; other platforms build from source.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ault-features

xgboost-sys is now depended on with default-features = false; previously
building xgb with --no-default-features --features use_prebuilt_xgb still
enabled the sys crate's default local_build, so Windows CI downloaded the
prebuilt binaries and then attempted a cmake source build. That build also
exposed a second bug: std canonicalize yields a \\?\ extended-length path
on Windows which breaks CMake's file(GLOB) ("No SOURCES given to target:
xgboost"); xgb_root now goes through dunce::canonicalize.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…>/lib, not the build dir

The main library (libxgboost.so/.dylib, xgboost.dll) is emitted into
xgboost-sys/xgboost/lib by xgboost's CMake; only libdmlc.a lands under the
build directory. Search both roots and fail with an explicit message when a
file is missing instead of cp's empty-operand error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The DLL lands in cmake's out dir (local_build) or target/<profile>/deps
(use_prebuilt_xgb) — neither is on the Windows loader's search path, so
binaries run outside `cargo run` (which patches PATH with the link-search
dirs) failed with "xgboost.dll was not found". Copy it into the profile
root at build time, where the final executables are emitted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Generalizes the Windows-only DLL staging to Linux (libxgboost.so) and
macOS (libxgboost.dylib), on both the local_build and use_prebuilt_xgb
paths. The local_build copy probes bin/, lib/, and lib64/ since cmake's
output layout varies by platform and distro.

Staging alone only fixes direct execution on Windows, where the loader
searches the exe's directory. On Linux/macOS the binary also needs an
$ORIGIN/@loader_path rpath, which Cargo cannot propagate from a
dependency's build script — documented in the README with build.rs and
.cargo/config.toml snippets for binary crates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ld flags

Removes the last per-call heap allocations on the inplace prediction hot
path and adds buffer-reuse APIs so a warm serving loop allocates nothing
on the Rust side.

- booster.rs: build array-interface JSON into a fixed 192-byte stack
  buffer (CBuf) instead of format! + CString. predict_from_dense did one
  String+CString per call; predict_from_csr did three. The owned
  predict_from_{dense,csr} now delegate to shared raw helpers.
- Add predict_from_dense_into / predict_from_csr_into, which write
  predictions into a caller-provided &mut Vec<f32> (cleared, capacity
  reused) rather than returning a fresh Vec.
- tests/zero_alloc.rs: counting global allocator proves the warm _into
  loop performs zero Rust-side allocations across 100 dense/CSR calls,
  with bit-identical results to the owned variants.
- xgboost-sys/build.rs: opt-in XGB_BUILD_NATIVE (-march/-mcpu=native)
  and XGB_BUILD_IPO (CMake IPO/ThinLTO) for the local_build C++ compile,
  off by default for portable binaries. IPO is passed explicitly ON/OFF
  so unsetting the env var reverts the cmake cache. rerun-if-env-changed
  wired for both.
- benches: new serving_nthread1 group (stable single-thread serving
  latency, owned vs _into, dense+CSR at 1/16/100 rows).
- README: document the serving-path guidance and build flags; fix stale
  claims (default is local_build, not use_prebuilt_xgb; XGBoost 3.2.0).

All 44 tests pass, including against a NATIVE+IPO build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The counting allocator used a process-global AtomicUsize, but libtest
runs tests on parallel threads (and prints progress from its own), so
other threads' allocations could land inside a test's measurement
window. On the slower Windows/Linux debug CI builds the two tests
overlapped and failed with small spurious counts (16 and 4 per 100
calls — well under one per call, the signature of cross-thread bleed
rather than a real wrapper allocation).

- Count allocations in a const-initialized thread_local Cell instead,
  so each test measures only its own thread. try_with in the allocator
  hook so it can never panic during thread teardown.
- Harden the property: each test now runs an allocator-hammering
  background thread during the measured window, making immunity to
  concurrent allocation deterministic instead of scheduling luck.
- Silence pre-existing CI warnings: declare deprecated_api/print_table
  as expected cfg values via [lints.rust] unexpected_cfgs, and gate
  generate_sparse_data / print_comparison_table under the same features
  as their only callers (fixes the dead_code warning).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Make inplace predict serving path allocation-free; add opt-in C++ bui…
Correctness:
- save_buffer passed a non-NUL-terminated String pointer as a C string
  to XGBoosterSaveModelToBuffer (heap overread); use static CStr configs.
- predict_margin config carried training:true from the legacy call,
  applying random tree-dropout per call on DART models; now false,
  matching Python's predict(output_margin=True).
- parse_eval_string cross-matched eval-set names that prefix another
  ("val"/"val2"); require the '-' separator.
- predict_from_dense / DMatrix::from_dense{,_quantile} silently truncated
  when values.len() wasn't divisible by num_rows and panicked on
  num_rows=0; both now return an error.
- DMatrix::slice segfaulted on out-of-bounds indices (the C API doesn't
  bounds-check); validate before the FFI call.
- XGBoost returns a null data pointer for empty predictions (0-row
  DMatrix): predict_raw asserted non-null (panic), and the naive fix
  would be from_raw_parts(null, 0) UB. All three raw predict paths now
  share predict_output_slices, which maps null+empty-shape to &[].
- predict_leaf/contributions/interactions divided by zero on 0-row
  matrices.

Performance:
- boost() built its gradient/hessian array-interface JSON with
  format!+CString (4 allocations per custom-objective round); build it
  in the existing CBuf stack buffers instead, byte-identical output.
  Proven by the new alloc-count test (exactly 2 allocs/round: the
  objective's own grad/hess Vecs) and a custom-vs-builtin logistic
  equivalence test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
XGBoost silently ignores unknown parameter keys (validate_parameters
defaults to false), so stale names fail silently. Audited every emitted
key/value against the bundled 3.2.0 source:

- verbose emitted "silent", removed as a parameter in 1.0 — the toggle
  did nothing. Now emits "verbosity" (0/2), so it works.
- predictor (removed 2.0) and sketch_eps (removed 1.7) were dead keys
  emitted on every run; removed (replacements: device, max_bin).
- base_score was unconditionally sent as 0.5, silently disabling 3.x's
  automatic intercept estimation (boost_from_average) on every model.
  Now Option<f32> defaulting to unset, matching Python defaults.
  BEHAVIOR CHANGE: models trained without an explicit base_score now
  boost from the estimated intercept. Golden-value tests pin 0.5.
- Removed variants that 3.x rejects at Configure time: gpu_exact /
  gpu_hist tree methods (string From impl shims them to exact/hist),
  distcol / grow_local_histmaker / grow_skmaker updaters (added the
  modern grow_quantile_histmaker), and the four gpu:* objectives.
- TweedieLogLoss emitted bare "tweedie-nloglik", which XGBoost rejects;
  now takes rho and emits "tweedie-nloglik@rho".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
agene0001 and others added 17 commits July 14, 2026 13:57
…guide

- unsafe impl Send for Booster and DMatrix: the C API keeps all return
  buffers in thread-local maps keyed by learner/DMatrix pointer
  (LearnerAPIThreadLocalStore), so handles have no thread affinity and a
  loaded model can move into worker threads. Sync stays unimplemented:
  concurrent &self predictions would race on the cached inplace proxy.
- predict_into / predict_margin_into: buffer-reuse variants for the
  DMatrix path, matching the inplace *_into APIs; steady-state batch
  scoring is now allocation-free (pinned by a new zero_alloc test).
- Record the per_round_overhead bench conclusion: the validate_features
  feature-info FFI getter costs ~37 ns vs ~2 ms per boosting round, so
  caching it is not worth doing.
- docs/SERVING.md: consumer-facing guide covering XGB_BUILD_NATIVE/IPO,
  nthread=1 for small batches, inplace prediction, buffer reuse, the
  Send-not-Sync threading model, and QuantileDMatrix training.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Build-flag env vars must be pinned in the final binary's workspace
(.cargo/config.toml does not travel with dependencies), and libraries
wrapping this crate should document the batch-size/threading regime
they tuned for so top-level consumers know which side of the ~1000-row
nthread crossover they are on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Distributional-regression callers (gradientlss) carry the training margin
across rounds and compute grad/hess from it, so update_custom's internal
predict is a wasted prediction-cache read per round for them — boost is
the entry point they need, and it was already safe to expose (multi-target
shape inference included).

New tests pin down the QuantileDMatrix contract those callers rely on:
base_margin and weights are settable post-construction (only the feature
data is frozen by binning), a 0-tree multi-target predict returns exactly
the base margin, and pub boost + cached predict round-trips build a real
multi-target model on a quantile matrix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…direct)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Param/API audit for 3.2.0 -> 3.3.0 (diffed the bundled C++ between tags):
- C-API surface unchanged: no entry points added or removed (only doc
  edits deprecating the GPU-only max_quantile_blocks config key, unused
  here).
- No wrapped parameter was renamed or removed; all pinned-value tests
  pass unchanged, so numerics on our paths are identical.
- New in 3.3: reg:expectileerror objective (expectile_alpha list param)
  and expectile metric. Not yet in the Objective enum (reachable via
  set_param, like reg:quantileerror); candidate follow-up.
- booster=dart is deprecated and internally remapped to gbtree (DART
  folded into the tree booster); booster=gblinear is deprecated with
  removal planned. Both still work — documented on BoosterType, and a
  new test pins that they keep training so a future bump that removes
  them fails loudly.
- gputreeshap is no longer a submodule of xgboost at 3.3.0.

build.rs: watch include/xgboost/version_config.h so a submodule version
bump triggers a C++ rebuild — without this, cargo silently reuses the
previous version's cached libxgboost (bit this bump; caught because the
build finished in seconds). Prebuilt release URL moved to the v3.3.0
tag, which the release-libs workflow populates when the tag is pushed.

Crate versions follow the bundled XGBoost: 3.3.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…arness

Point the xgboost submodule at agene0001/xgboost branch
serving-patches-3.3.0: v3.3.0 plus two patches driven by profiling the
single-row nthread=1 inplace-predict loop (examples/profile_predict.rs,
committed here as the reusable harness):

- context.cc: cache the two per-call std::regex constructions in device
  parsing and fast-path device=cpu. DMatrixProxy::SetArray re-inits its
  context every inplace predict, so this was ~27% of single-row latency.
- cpu_predictor.cc: skip the per-tree MaxDepth() precalculation when
  n_samples <= 1; DispatchArrayLayout never reads it for size-1 blocks.
  Another ~21% of single-row latency.

Combined: 7.43us -> 4.31us per single-row call (-42%), bit-identical
predictions, full test suite green in debug and release.

Both patches are also staged as branches off dmlc master on the fork
(fix-device-parse-regex, fix-single-row-depth-precalc) for upstream
PRs; when upstream ships them, repoint the submodule at the official
tag and drop this divergence.
…3 parity)

- Objective::RegQuantile(Vec<f32>) / RegExpectile(Vec<f32>): first-class
  variants for reg:quantileerror and the new-in-3.3 reg:expectileerror,
  emitting the alpha list in the [a,b,...] form ParamArray parses. One
  prediction column per alpha; builder validates alphas non-empty and in
  (0, 1). Objective loses Copy (Vec payloads) — Clone remains.
- DMatrix::set/get_feature_types + set/get_feature_names via
  XGDMatrix{Set,Get}StrFeatureInfo. Marking a column "c" enables
  categorical splits (on by default since XGBoost 3.3); previously the
  wrapper had no way to use categorical features at all.
- Tests: multi-quantile/expectile output shape and column ordering,
  builder validation, and a categorical training test asserting the
  split separates the informative category.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
serving-patches-3.3.0 gains a third patch (also submitted upstream as a
follow-up to dmlc/xgboost#12311): parse object keys as plain strings,
insert with moves, construct unescaped string literals in one piece,
and move array elements. Single-row inplace predict: 4665 -> 4272
ns/call (~9%), bit-identical predictions; JSON model loading shares the
same reader. Full test suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- [profile.release] lto="thin" + codegen-units=1. The hot path is inside
  libxgboost so the Rust-side gain is small, but it is free and removes a
  confound when comparing this wrapper against other bindings. bench
  inherits release, so criterion measures shipped codegen.
- XGB_BUILD_NATIVE now defaults ON: local_build compiles the C++ on the
  machine that runs it, so "portable across CPUs" protected nobody by
  default while making every default build (and forgotten-env benchmark)
  measure the slow path. Cross-machine deploys opt out with =0. Release
  CI assets are unaffected (they invoke cmake directly, not build.rs).
- HIDE_CXX_SYMBOLS=ON for the shared build: the C API keeps its explicit
  visibility("default"), so linking is unaffected; cross-TU calls inside
  libxgboost stop being interposable on Linux ELF.
- XGB_CMAKE_DEFINES="KEY=VAL;..." passthrough for perf experiments
  (USE_OPENMP=OFF, BUILD_STATIC_LIB=ON, ...) without editing build.rs.
- Watch xgboost/src and xgboost/include for rebuilds: watching only
  version_config.h silently measured stale code after hand-edits to the
  carried C++ patches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Correctness / FFI hardening:
- predict_matrix: reject interior-NUL configs (from_bytes_with_nul) instead
  of silently truncating at the first NUL.
- validate_features uses XGBoosterGetNumFeature instead of counting feature
  *names* (valid for models without names; skip-on-error keeps behavior).
- feature-name/type getters and setters return Err on interior-NUL /
  non-UTF-8 (possible from a model written by another binding) instead of
  panicking.
- set_objective validates alphas (shared with the builder), closing a
  bypass that surfaced as an opaque C++ CHECK at the first update.
- Multi-output doc fixes on predict/predict_into; pin predict_margin_into
  in tests/zero_alloc.rs (the last uncovered predict-into variant).
- Document the set_feature_types stale-gradient-index footgun.

Data-ingestion APIs (fill gaps that forced user-side full copies):
- from_dense/from_csr/from_csc_with_missing: custom missing sentinel
  dropped during XGBoost's own ingestion pass; uniform finite-or-NaN
  contract. Booster::set_inplace_predict_missing for the serving path
  (config built once; hot path stays allocation-free).
- from_dense_quantile_ref / from_csr_quantile_ref: eval sets share the
  training matrix's bin cuts (Python's QuantileDMatrix(ref=...)).
- from_dense_f64: <f8 array-interface ingestion; f64->f32 narrowing folds
  into XGBoost's mandatory copy.

Training config surface (the loop is at ceiling; the gaps were around it):
- TrainingParameters.eval_period + verbose_eval: evaluation sets no longer
  force a full prediction pass + stdout print every round.
- BoosterParameters.device (Device enum): GPU training is now reachable
  through typed params; the removed gpu_hist/gpu_exact tree methods warn
  loudly instead of silently mapping to CPU.
- SamplingMethod + max_cat_to_onehot/max_cat_threshold typed params.
- Rewrite stale TreeMethod docs (auto == hist since 2.0; exact is legacy).
- SERVING.md/README: document the measured levers (max_bin, eval cadence,
  QuantileDMatrix as a memory win not a speed win, GPU guidance).
- examples/profile_train.rs: reusable training profiling harness.

Full test suite green (53 lib + 5 zero-alloc + 6 doc).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
serving-patches-3.3.0 gains a fourth patch: CacheManager fell through to
compiled cache defaults (L1=32KB, L2=1MB) on Apple silicon, where the real
figures are 128KB/64KB L1d and 16MB/4MB L2 (M3 Pro P/E cores). The hist
tree method sizes histogram row blocks and picks the row-vs-column build
kernel from these, so blocks were sized several times too small and the
kernel-selection threshold sat at ~838KB instead of ~3.4MB.

The patch reads hw.perflevel<N>.l1dcachesize/l2cachesize via sysctlbyname
(min across performance levels), isolated by preprocessor to non-x86_64
macOS. Measured (hist, depth 6, 100k rows, min round of 3 runs): 64 cols
8.17->7.39 ms/round; 512 cols 68.99->63.88 ms/round. Full test suite green.

Also submitted upstream as dmlc/xgboost#12362 (branch
fix-macos-cache-detection on the fork); repoint to the official tag and
drop this divergence when it merges.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Re-pins the submodule onto the revert of 791afcace. That patch was a
measured regression, not the ~5-9% win claimed when it landed in e1d38fd:
the original number came from comparing builds batched (all-A then all-B)
with min-of-min round times, which on this machine confounds the build
difference with background-load and thermal drift.

Re-measured interleaved (variant alternated every iteration, rebuild
between each run): no difference at 500k x 64 (median round 28.14 vs
28.19 ms, 6 pairs), and the patch loses all 4 pairs at 200k x 512 --
median round ~172 vs ~135 ms, total ~7567 vs ~6996 ms, i.e. ~12-28%
slower where it actually flips histogram kernel selection.

Root cause of the bad premise: hw.perflevel*.l2cachesize is the
cluster-shared L2, but ReadByColumn treats L2 as a roughly per-thread
budget, so the "real" figure overestimates per-thread capacity and picks
the row-wise kernel when the working set does not fit. The conservative
1MB default is closer to the effective per-core budget.

Upstream PR dmlc/xgboost#12362 closed with the same correction. The
branch keeps the three merged-upstream serving patches (device regex,
single-row depth precalc, JSON parser allocations) unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Upstream XGBoost's CMake links the shared library into the SOURCE tree
(<xgboost>/lib) by default. Under cargo that directory is shared by every
profile and every concurrent cargo invocation using the same checkout, so
two builds racing through the ninja link step fail with

  LNK1104: cannot open file '...\xgboost-sys\xgboost\lib\xgboost.dll'

(observed: a background `cargo build` overlapping `cargo run --profile
production` right after a pin bump, when the native lib had to be rebuilt).
Cargo's target-dir lock does not cover the git checkout, so nothing
serializes the two linkers.

Fix: define KEEP_BUILD_ARTIFACTS_IN_BINARY_DIR=ON (upstream option since
CMakeLists.txt line 73) so the link output stays under cmake's per-OUT_DIR
binary dir. The install step still places the runtime library in <dst>/bin
and the import/static libs in <dst>/lib, which is exactly where the
link-search paths and the staging candidates already look — verified on
Windows: source tree stays clean, xgboost.dll lands in out/bin + staged
next to the executables, xgboost.lib/dmlc.lib in out/lib, and
`cargo test -p xgb --no-run` links end to end.

The define sits before the XGB_CMAKE_DEFINES escape hatch, so it can still
be overridden per build if anyone ever wants the old layout back.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Windows CI silently tested the 3.3 wrapper against XGBoost 3.0.0 binaries
for over a week: the v3.3.0 release was never published, so build.rs fell
back to the legacy assets and the skew only surfaced as an "Unknown
objective function: reg:expectileerror" test failure. Three invariants
were maintained by hand; now each fails loudly instead.

- The release tag is derived from CARGO_PKG_VERSION rather than hardcoded,
  so it cannot drift from the crate version on a bump.
- A missing release asset is a hard error naming the workflow to run,
  instead of a silent downgrade. The XGBoost 3.0.0 fallback mixes ABIs
  against the bundled headers, so it is now opt-in via
  XGB_ALLOW_LEGACY_PREBUILT=1 (still warns loudly when used).
- build.rs warns when the crate version disagrees with the bundled
  submodule's version_config.h — a submodule bump that forgot the
  Cargo.toml bump would otherwise point at the wrong release tag. A
  warning, not a panic, so an intentional skew or a user-supplied
  XGBOOST_LIB_DIR stays buildable.

release-libs.yml gains a `verify` job that resolves the tag once and gates
the build on crate version == root version == bundled XGBoost == tag
(previously the dispatch input defaulted to a stale "v3.2.0" literal,
which would have published assets under a tag build.rs never looks up).
The release job additionally refuses to publish an incomplete asset set,
since a missing platform breaks only that platform's consumers.

Verified: skew warning fires on a simulated submodule bump; the prebuilt
path hard-errors with the derived tag when no release exists; the opt-in
fallback still works. Full suite green (53 + 5 + 6).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Upstream's three new commits (through 86ad557) rebuild the prebuilt
path around SHA-256-pinned artifacts fetched from marcomq/rust-xgboost
at tag v3.0.5, switch the downloader from reqwest to ureq+sha2, and
restore use_prebuilt_xgb as the default feature.

None of that can be adopted here. This fork bundles XGBoost 3.3.0 (the
submodule points at agene0001/xgboost) and publishes its own release
assets, with the tag derived from CARGO_PKG_VERSION so it cannot drift
from the crate version. Upstream's checksums are for 3.0.5 binaries and
can never match an asset published from this fork, and linking 3.0.x
libraries against 3.3.0 headers is exactly the version skew that
fetch_lib already refuses by design - it previously surfaced as an
"Unknown objective function: reg:expectile" failure at run time rather
than as a build error.

Resolved by keeping this fork's xgboost-sys/build.rs, Cargo.toml
versions and feature defaults (default = local_build) in full. Upstream
touched nothing outside that scheme, so the merged tree is unchanged
from the previous branch tip; the merge records the shared history so
these conflicts do not resurface on the next sync.

Also dropped upstream's release tooling that came in with the merge
(.github/workflows/prebuilt-libs.yml, scripts/upload-release-libs.sh):
it publishes v3.0.5 binaries under the scheme this fork does not use,
duplicating the existing release-libs.yml.

Upstream's content-hash verification is worth having and is ported onto
this fork's downloader in a follow-up commit.
Ports the one idea worth keeping from upstream's competing prebuilt
scheme onto this fork's downloader. error_for_status already stops a
404 page being written out as a library, but it cannot catch a
truncated transfer, a body mangled by an intercepting proxy, or a
release asset re-uploaded with different contents under a tag that was
already consumed. None of those are hypothetical for assets fetched
over the network on every clean build.

The digest is checked before the bytes are written, so a rejected
download never lands on disk where a later build would find the file
present and skip the fetch entirely. A mismatch returns an error rather
than panicking, so fetch_lib treats a bad asset the same as a missing
one and its existing fallback still applies; the panic message now
carries the underlying cause so a mismatch is not reported as "the
release was never published".

Digests for all eight v3.3.0 assets are recorded. An asset with no
entry is downloaded with a warning rather than failing the build, so
adding a platform does not need a digest up front;
XGB_REQUIRE_CHECKSUMS=1 turns that into an error for CI.

Also adds XGBOOST_LIB_URL to point the same flat <platform>-<file>
layout at a mirror, and declares rerun-if-env-changed for the lookup
variables so a build actually reruns when they change.

Verified: assets re-downloaded from a clean deps/ pass verification and
the crate's 64 tests pass against the result; corrupting a recorded
digest fails the build with the mismatch as the reported cause.

Corrected two stale README claims while documenting this: the crate
builds against XGBoost 3.3.0, not 3.2.0, and local_build is the default
feature here, not use_prebuilt_xgb.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants