Skip to content

Commit 61267ca

Browse files
committed
Fix QuantileDMatrix OOM with sparse polars Categorical codes
A polars Categorical built against a primed StringCache holds a few unique strings at very large physical codes. AddCategories / MakeCuts emitted a dense [0..max_observed_code] cut layout per categorical feature, blowing cut_values to O(max_code) and triggering STATUS_STACK_BUFFER_OVERRUN on Windows when the saved-tree bitfield was sized off cuts.MaxCategory()+1. Store one cut per observed code; size MaxNumBinPerFeat() and saved-tree cat_bits via per-feature observed range. Add a content-hash check (AllreduceDigestAndCheck) so distributed runs surface divergent ref dictionaries before training. Mutex-guard CatContainer Sort/Copy and round-trip is_ref/sorted across Save/Load. Bench (primer=500k, n_real_cats=16, 8 boost rounds, identical build): CPU train 5.42s -> 0.28s (19.5x), GPU train 6.78s -> 0.19s (36x), peak RSS ~40% lower. Predictions byte-identical to master across both regimes. Equivalent to master in dense-codes regime (typical pandas LabelEncoder output). Closes #12177
1 parent e0d3dfd commit 61267ca

36 files changed

Lines changed: 1380 additions & 108 deletions

include/xgboost/data.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ class MetaInfo {
230230
[[nodiscard]] CatContainer const* Cats() const;
231231
[[nodiscard]] CatContainer* Cats();
232232
[[nodiscard]] std::shared_ptr<CatContainer const> CatsShared() const;
233+
[[nodiscard]] std::shared_ptr<CatContainer> CatsShared();
233234
/**
234235
* @brief Setter for categories.
235236
*/
@@ -726,6 +727,7 @@ class DMatrix {
726727
[[nodiscard]] std::shared_ptr<CatContainer const> CatsShared() const {
727728
return this->Info().CatsShared();
728729
}
730+
[[nodiscard]] std::shared_ptr<CatContainer> CatsShared() { return this->Info().CatsShared(); }
729731

730732
protected:
731733
virtual BatchSet<SparsePage> GetRowBatches() = 0;

ops/conda_env/aarch64_test.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ dependencies:
2626
- llvmlite
2727
- loky>=3.5.1
2828
- pyarrow
29+
- polars
2930
- pyspark>=4.0.0
3031
- cloudpickle
3132
- pip:

ops/conda_env/macos_cpu_test.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,5 @@ dependencies:
2828
- awscli
2929
- loky>=3.5.1
3030
- pyarrow
31+
- polars
3132
- cloudpickle

ops/conda_env/win64_test.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@ dependencies:
1717
- py-ubjson
1818
- loky>=3.5.1
1919
- pyarrow
20+
- polars

src/common/categorical.h

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,19 @@ XGBOOST_DEVICE bst_cat_t AsCat(T const& v) {
2121
return static_cast<bst_cat_t>(v);
2222
}
2323

24+
/**
25+
* @brief Storage size for a CatBitField whose largest valid bit is @p max_code.
26+
*
27+
* Widens to size_t before +1 so max_code near INT32_MAX cannot trigger signed-overflow
28+
* UB on bst_cat_t = int32_t.
29+
*
30+
* @return Storage size in @c CatBitField::value_type units.
31+
*/
32+
[[nodiscard]] inline std::size_t SizeCatBitsForMaxCode(bst_cat_t max_code) {
33+
CHECK_GE(max_code, 0);
34+
return CatBitField::ComputeStorageSize(static_cast<std::size_t>(max_code) + 1);
35+
}
36+
2437
/* \brief Whether is fidx a categorical feature.
2538
*
2639
* \param ft Feature type for all features.

src/common/quantile.cc

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -533,14 +533,18 @@ void AddCategories(std::set<float> const &categories, float *max_cat, HistogramC
533533
InvalidCategory();
534534
}
535535
auto &cut_values = cuts->cut_values_.HostVector();
536-
// With column-wise data split, the categories may be empty.
537-
auto feature_max_cat =
538-
categories.empty() ? 0.0f : *std::max_element(categories.cbegin(), categories.cend());
536+
if (categories.empty()) {
537+
// column-wise split: emit a placeholder cut and treat the synthetic 0.0f as the
538+
// observed max so downstream sizing (evaluator.cu MaxCategory()+1) does not see -1
539+
cut_values.push_back(0.0f);
540+
*max_cat = std::max(*max_cat, 0.0f);
541+
return;
542+
}
543+
auto feature_max_cat = *std::max_element(categories.cbegin(), categories.cend());
539544
CheckMaxCat(feature_max_cat, categories.size());
540545
*max_cat = std::max(*max_cat, feature_max_cat);
541-
for (bst_cat_t i = 0; i <= AsCat(feature_max_cat); ++i) {
542-
cut_values.push_back(i);
543-
}
546+
// one cut per observed code; categories is sorted ascending
547+
cut_values.insert(cut_values.end(), categories.cbegin(), categories.cend());
544548
}
545549

546550
HistogramCuts HostSketchContainer::MakeCuts(Context const *ctx, MetaInfo const &info) {

src/common/quantile.cu

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -705,16 +705,23 @@ HistogramCuts SketchContainer::MakeCuts(Context const *ctx, bool is_column_split
705705
auto column = Span<SketchEntry const>{h_entries.data() + begin, end - begin};
706706

707707
if (IsCat(h_feature_types, i)) {
708-
auto column_size = std::max(static_cast<std::size_t>(1), column.size());
709-
auto feature_max = column.empty() ? 0.0f : column.back().value;
710-
if (std::any_of(column.cbegin(), column.cend(),
711-
[](auto const &entry) { return InvalidCat(entry.value); })) {
712-
InvalidCategory();
713-
}
714-
CheckMaxCat(feature_max, column_size);
715-
max_cat = std::max(max_cat, feature_max);
716-
for (std::size_t cat = 0; cat <= static_cast<std::size_t>(feature_max); ++cat) {
717-
h_out_cut_values.push_back(cat);
708+
if (column.empty()) {
709+
// column-split worker with no rows: emit a placeholder cut and treat the
710+
// synthetic 0.0f as observed max so MaxCategory() is never -1
711+
h_out_cut_values.push_back(0.0f);
712+
max_cat = std::max(max_cat, 0.0f);
713+
} else {
714+
auto feature_max = column.back().value;
715+
if (std::any_of(column.cbegin(), column.cend(),
716+
[](auto const &entry) { return InvalidCat(entry.value); })) {
717+
InvalidCategory();
718+
}
719+
CheckMaxCat(feature_max, column.size());
720+
max_cat = std::max(max_cat, feature_max);
721+
// one cut per observed physical code; column sorted ascending
722+
for (auto const &entry : column) {
723+
h_out_cut_values.push_back(entry.value);
724+
}
718725
}
719726
} else {
720727
summary.Reserve(column.size());

src/data/adapter.cc

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,12 @@
1919

2020
namespace xgboost::data {
2121
namespace {
22-
auto GetRefCats(Json handle) {
23-
auto cats = reinterpret_cast<CatContainer const*>(get<Integer const>(handle));
22+
// Pair the owning ref CatContainer pointer with its host view
23+
[[nodiscard]] std::pair<CatContainer*, enc::HostColumnsView> GetRefCats(Json handle) {
24+
auto cats = reinterpret_cast<CatContainer*>(get<Integer const>(handle));
2425
CHECK(cats);
2526
auto h_cats = cats->HostView();
26-
return h_cats;
27+
return {cats, h_cats};
2728
}
2829
} // anonymous namespace
2930

@@ -32,7 +33,9 @@ ColumnarAdapter::ColumnarAdapter(StringView columns) {
3233

3334
if (IsA<Object>(jdf)) {
3435
// Has reference categories.
35-
this->ref_cats_ = GetRefCats(jdf["ref_categories"]);
36+
auto [ref_cats_ptr, ref_cats_view] = GetRefCats(jdf["ref_categories"]);
37+
this->ref_cats_ptr_ = ref_cats_ptr;
38+
this->ref_cats_ = ref_cats_view;
3639
jdf = jdf["columns"];
3740
}
3841

src/data/adapter.h

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
#include <cstdint> // for uint8_t
1212
#include <limits> // for numeric_limits
1313
#include <memory> // for unique_ptr, make_unique
14-
#include <utility> // for move
14+
#include <mutex> // for once_flag, call_once
15+
#include <utility> // for move, forward
1516
#include <variant> // for variant
1617
#include <vector> // for vector
1718

@@ -437,6 +438,11 @@ using EncColumnarAdapterBatch = EncColumnarAdapterBatchImpl<CatAccessor>;
437438
class ColumnarAdapter : public detail::SingleBatchDataIter<ColumnarAdapterBatch> {
438439
std::vector<ArrayInterface<1>> columns_;
439440
enc::HostColumnsView ref_cats_;
441+
// non-owning; pointee outlives the adapter (caller keeps the ref DMatrix alive)
442+
CatContainer* ref_cats_ptr_{nullptr};
443+
// cached Recode mapping; write-once via CachedRefMapping()
444+
mutable std::once_flag cache_once_;
445+
mutable std::vector<std::int32_t> cached_ref_mapping_;
440446
std::vector<enc::HostCatIndexView> cats_;
441447
std::vector<std::int32_t> cat_segments_;
442448
ColumnarAdapterBatch batch_;
@@ -454,6 +460,12 @@ class ColumnarAdapter : public detail::SingleBatchDataIter<ColumnarAdapterBatch>
454460
*/
455461
explicit ColumnarAdapter(StringView columns);
456462

463+
// non-copyable and non-movable (owns std::once_flag)
464+
ColumnarAdapter(ColumnarAdapter const&) = delete;
465+
ColumnarAdapter& operator=(ColumnarAdapter const&) = delete;
466+
ColumnarAdapter(ColumnarAdapter&&) = delete;
467+
ColumnarAdapter& operator=(ColumnarAdapter&&) = delete;
468+
457469
[[nodiscard]] ColumnarAdapterBatch const& Value() const override { return batch_; }
458470

459471
[[nodiscard]] bst_idx_t NumRows() const {
@@ -474,18 +486,44 @@ class ColumnarAdapter : public detail::SingleBatchDataIter<ColumnarAdapterBatch>
474486
static_cast<std::int32_t>(this->cat_segments_.back())};
475487
}
476488
[[nodiscard]] enc::HostColumnsView RefCats() const { return this->ref_cats_; }
489+
// non-owning; pointee outlives the adapter; non-const so dispatchers can call Sort()
490+
// on the ref CatContainer through a const adapter
491+
[[nodiscard]] CatContainer* RefCatsPtr() const { return this->ref_cats_ptr_; }
477492
[[nodiscard]] common::Span<ArrayInterface<1> const> Columns() const { return this->columns_; }
493+
494+
/** @brief Cached Recode mapping; first call wins, later calls ignore @p builder.
495+
*
496+
* @warning The returned span aliases adapter storage; lifetime <= adapter.
497+
*/
498+
template <typename Fn>
499+
[[nodiscard]] common::Span<std::int32_t const> CachedRefMapping(Fn&& builder) const {
500+
std::call_once(this->cache_once_,
501+
[&] { this->cached_ref_mapping_ = std::forward<Fn>(builder)(); });
502+
return common::Span<std::int32_t const>{this->cached_ref_mapping_};
503+
}
478504
};
479505

480-
inline auto MakeEncColumnarBatch(Context const* ctx, ColumnarAdapter const* adapter) {
481-
auto cats = std::make_unique<CatContainer>(adapter->RefCats(), true);
482-
cats->Sort(ctx);
483-
auto [acc, mapping] = cpu_impl::MakeCatAccessor(ctx, adapter->Cats(), cats.get());
484-
return std::tuple{EncColumnarAdapterBatch{adapter->Columns(), acc}, std::move(mapping)};
506+
inline EncColumnarAdapterBatch MakeEncColumnarBatch(Context const* ctx,
507+
ColumnarAdapter const* adapter) {
508+
// alias the reference dictionary when available; Sort() is idempotent under sort_mu_
509+
auto* ref_cats_ptr = adapter->RefCatsPtr();
510+
if (ref_cats_ptr != nullptr) {
511+
ref_cats_ptr->Sort(ctx);
512+
auto cached = adapter->CachedRefMapping([&] {
513+
[[maybe_unused]] auto [acc, mapping] =
514+
cpu_impl::MakeCatAccessor(ctx, adapter->Cats(), ref_cats_ptr);
515+
return std::move(mapping);
516+
});
517+
auto cats_mapping = enc::MappingView{adapter->Cats().feature_segments, cached};
518+
return EncColumnarAdapterBatch{adapter->Columns(), CatAccessor{cats_mapping}};
519+
}
520+
CHECK(!adapter->HasRefCategorical())
521+
<< "ColumnarAdapter has reference categorical view but no CatContainer pointer.";
522+
return EncColumnarAdapterBatch{adapter->Columns(), CatAccessor{}};
485523
}
486524

487-
inline auto MakeEncColumnarBatch(Context const* ctx,
488-
std::shared_ptr<ColumnarAdapter> const& adapter) {
525+
inline EncColumnarAdapterBatch MakeEncColumnarBatch(
526+
Context const* ctx, std::shared_ptr<ColumnarAdapter> const& adapter) {
489527
return MakeEncColumnarBatch(ctx, adapter.get());
490528
}
491529

src/data/cat_container.cc

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
#include <algorithm> // for copy
77
#include <cstddef> // for size_t
88
#include <memory> // for make_unique
9+
#include <mutex> // for lock_guard, scoped_lock
910
#include <utility> // for move
1011
#include <vector> // for vector
1112

@@ -16,6 +17,37 @@
1617
#include "xgboost/json.h" // for Json
1718

1819
namespace xgboost {
20+
namespace {
21+
// Validate Arrow StringArray offset invariants before the copy; malformed offsets cause
22+
// SortNames to compute OOB substrings and break stable_sort's strict-weak-ordering.
23+
void ValidateCatStrArrayOffsets(enc::CatStrArrayView const& str) {
24+
if (str.offsets.empty()) {
25+
return;
26+
}
27+
constexpr auto kHint =
28+
" The producing dataframe library is emitting inconsistent Arrow data; update it"
29+
" to the latest version.";
30+
CHECK_EQ(str.offsets.front(), 0)
31+
<< "Malformed Arrow categorical dictionary: offsets[0] must be 0." << kHint;
32+
auto const n = str.offsets.size();
33+
for (std::size_t i = 0; i < n; ++i) {
34+
auto const off = str.offsets[i];
35+
CHECK_GE(off, 0)
36+
<< "Malformed Arrow categorical dictionary: offsets[" << i << "] = " << off
37+
<< " is negative." << kHint;
38+
if (i + 1 < n) {
39+
CHECK_LE(off, str.offsets[i + 1])
40+
<< "Malformed Arrow categorical dictionary: offsets not monotonic at i=" << i
41+
<< "." << kHint;
42+
}
43+
}
44+
auto last = static_cast<std::size_t>(str.offsets.back());
45+
CHECK_LE(last, str.values.size())
46+
<< "Malformed Arrow categorical dictionary: last offset " << last
47+
<< " exceeds values buffer size " << str.values.size() << "." << kHint;
48+
}
49+
} // namespace
50+
1951
CatContainer::CatContainer(enc::HostColumnsView const& df, bool is_ref) : CatContainer{} {
2052
this->is_ref_ = is_ref;
2153
this->n_total_cats_ = df.n_total_cats;
@@ -30,6 +62,7 @@ CatContainer::CatContainer(enc::HostColumnsView const& df, bool is_ref) : CatCon
3062
for (auto const& col : df.columns) {
3163
std::visit(enc::Overloaded{
3264
[this](enc::CatStrArrayView str) {
65+
ValidateCatStrArrayOffsets(str);
3366
using T = typename cpu_impl::ViewToStorageImpl<enc::CatStrArrayView>::Type;
3467
this->cpu_impl_->columns.emplace_back();
3568
this->cpu_impl_->columns.back().emplace<T>();
@@ -116,6 +149,8 @@ struct PrimToUbj<double> {
116149
} // anonymous namespace
117150

118151
void CatContainer::Save(Json* p_out) const {
152+
// serializes the full container snapshot against Sort()/Copy()
153+
std::lock_guard guard{sort_mu_};
119154
[[maybe_unused]] auto _ = this->HostView();
120155
auto& out = *p_out;
121156

@@ -166,6 +201,9 @@ void CatContainer::Save(Json* p_out) const {
166201
out["sorted_idx"] = std::move(jsorted_index);
167202
out["feature_segments"] = std::move(jf_segments);
168203
out["enc"] = arr;
204+
// persist is_ref_ and sorted_; optional fields for back-compat with pre-field models
205+
out["is_ref"] = Boolean{this->is_ref_};
206+
out["sorted"] = Boolean{this->sorted_};
169207
}
170208

171209
namespace {
@@ -187,6 +225,8 @@ void LoadJson(Json jvalues, Vec* p_out) {
187225
} // namespace
188226

189227
void CatContainer::Load(Json const& in) {
228+
// serializes the full container snapshot against Sort()/Copy()
229+
std::lock_guard guard{sort_mu_};
190230
auto array = get<Array const>(in["enc"]);
191231
auto n_features = array.size();
192232

@@ -266,6 +306,19 @@ void CatContainer::Load(Json const& in) {
266306
auto& h_sorted_idx = this->sorted_idx_.HostVector();
267307
LoadJson<std::int32_t>(in["sorted_idx"], &h_sorted_idx);
268308

309+
// back-compat: missing fields default to is_ref=false, sorted=!sorted_idx.empty()
310+
auto const& obj = get<Object const>(in);
311+
if (auto it = obj.find("is_ref"); it != obj.cend()) {
312+
this->is_ref_ = get<Boolean const>(it->second);
313+
} else {
314+
this->is_ref_ = false;
315+
}
316+
if (auto it = obj.find("sorted"); it != obj.cend()) {
317+
this->sorted_ = get<Boolean const>(it->second);
318+
} else {
319+
this->sorted_ = !h_sorted_idx.empty();
320+
}
321+
269322
this->cpu_impl_->Finalize();
270323
}
271324

@@ -275,6 +328,12 @@ CatContainer::CatContainer() : cpu_impl_{std::make_unique<cpu_impl::CatContainer
275328
CatContainer::~CatContainer() = default;
276329

277330
void CatContainer::Copy(Context const* ctx, CatContainer const& that) {
331+
if (&that == this) {
332+
return;
333+
}
334+
// scoped_lock serializes concurrent a.Copy(b)+b.Copy(a); this->device_mu_ guards
335+
// destination writes against a concurrent this->HostView() on another thread
336+
std::scoped_lock guard{this->sort_mu_, that.sort_mu_, this->device_mu_};
278337
[[maybe_unused]] auto h_view = that.HostView();
279338
this->CopyCommon(ctx, that);
280339
this->cpu_impl_->Copy(that.cpu_impl_.get());
@@ -290,9 +349,16 @@ void CatContainer::Copy(Context const* ctx, CatContainer const& that) {
290349

291350
void CatContainer::Sort(Context const* ctx) {
292351
CHECK(ctx->IsCPU());
352+
// sort_mu_ serializes Sort()/Copy(); HasCategorical() reads n_total_cats_ which
353+
// Copy() writes under sort_mu_, so check inside the lock
354+
std::lock_guard guard{sort_mu_};
355+
if (!this->HasCategorical() || this->sorted_) {
356+
return;
357+
}
293358
auto view = this->HostView();
294359
this->sorted_idx_.HostVector().resize(view.n_total_cats);
295360
enc::SortNames(enc::Policy<EncErrorPolicy>{}, view, this->sorted_idx_.HostSpan());
361+
this->sorted_ = true;
296362
}
297363
#endif // !defined(XGBOOST_USE_CUDA)
298364

0 commit comments

Comments
 (0)