Skip to content

Conversation

@razdoburdin
Copy link
Contributor

Current version of xgboost utilizes fixed block_size = 256 for hist building.

This PR make this value an adaptive function of model parameters and CPU cache size. The change is important mostly for ColsWiseBuildHistKernel and demonstrates up to 2x speed-up for epsilon dataset.

@razdoburdin razdoburdin marked this pull request as draft November 12, 2025 17:18
@trivialfis
Copy link
Member

Thank you for the optimizations! The code looks reasonable, but please add comments when the PR is ready for review. (and ping me).

std::size_t occupied_space = (hist_fit_to_l1 ? hist_size : 0) + offsets_size + idx_bin_size;
space_in_l1_for_rows = usable_l1_size > occupied_space ? usable_l1_size - occupied_space : 0;
}
std::size_t block_size = std::max<std::size_t>(1, space_in_l1_for_rows / l1_row_foot_print);
Copy link

Choose a reason for hiding this comment

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

Previously block_size was always 256 rows, which is quite large. And now it is 1 row in case no more rows fit into L1. Won't this change affect the performance in the case when there are no enough space for rows in L1?
Should it be max(256, space_in_l1_for_rows / l1_row_foot_print) ?
Or maybe L2 size should be used to calculate the block_size?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think, cacheline_size / (2 * sizeof(float) = 8, would be the best value in this case. Using L2 would result to a huge block_size (~1e4-1e5) and produce potential underutilization of CPU cores (blocks are processed in parallel, and if blocks a very big, than some cores would be out of job).

@razdoburdin razdoburdin marked this pull request as ready for review November 13, 2025 16:20
@razdoburdin
Copy link
Contributor Author

Hi @trivialfis , this PR is ready for review.

Copy link
Member

@trivialfis trivialfis left a comment

Choose a reason for hiding this comment

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

Would you like to explain the cache info in code comments? Also, the construction of hist space on how and why it depends on the cache size?

@razdoburdin
Copy link
Contributor Author

Would you like to explain the cache info in code comments? Also, the construction of hist space on how and why it depends on the cache size?

done

GetCacheInfo(cache_num++, &type, &level, &sets, &line_size, &partitions, &ways);
if (!trust_cpuid) return trust_cpuid;

if (type == kCpuidTypeNull) break;
Copy link
Member

Choose a reason for hiding this comment

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

Is the cache_sizes[idx] valid if we break here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

In this case we use default values from SetDefaultCaches

Copy link
Member

Choose a reason for hiding this comment

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

If this loop breaks, the function returns true, then this line does not execute:

  if (!trust_cpuid) SetDefaultCaches();

are we using the default values?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

if the loop breaks, it means CPU doesn't have all 4 cache levels, but all values being already read are correct. I have made some refactoring to make this part more clear.

std::size_t n_bins = gidx.cut.Ptrs().back();
std::size_t n_columns = gidx.cut.Ptrs().size() - 1;
bool any_missing = !gidx.IsDense();
std::size_t hist_size = 2 * sizeof(double) * n_bins;
Copy link
Member

Choose a reason for hiding this comment

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

Consider using sizeof(GradientPair) and sizeof(GradientPairPrecise) instead of sizeof(float) * 2 (for all sizeof calls in this PR).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

done

Copy link
Member

@trivialfis trivialfis Nov 17, 2025

Choose a reason for hiding this comment

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

Hmm, I made the comment for line 286, which is not done.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

fixed

*/

/* First step: determine whether one histogram column fits into L1.
* The maximum number of elements in a column is 2^8, 2^16, or 2^32,
Copy link
Member

Choose a reason for hiding this comment

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

Could you please elaborate on what it means to be the maximum number of elements in a (histogram) column? I thought that's the number of histogram bins?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

you are right, bins is a correct term. I have fixed the description.

Copy link
Member

Choose a reason for hiding this comment

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

Thank you for updating the comments. It's still not clear to me what it means to have "maximum number of bins" in a column. So, what happens if I specify the training parameter max_bin=53?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

You are right, it is better to use max_bin in this case, otherwise the estimation would be too conservative. I have updated the code.

Copy link
Member

Choose a reason for hiding this comment

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

You are right, it is better to use max_bin in this case, otherwise the estimation would be too conservative

I didn't make any suggestion? I was curious about the constraint and where the numbers 2^8, 2^16 originate or what they are for.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

ok, I have assumed that max_bin would be 2^8, 2^16 or 2^32 as a limit cases for BinTypeSize = 1, 2 or 4. It is better to use the exact max_bin value to have more accurate estimation.

/* First step: determine whether one histogram column fits into L1.
* Note: column-wise kernel is used for dense data only.
*/
std::size_t hist_col_size = 2 * sizeof(double) * max_bin;
Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

fixed

Dmitry Razdoburdin added 2 commits November 18, 2025 02:51
Copy link
Member

@trivialfis trivialfis left a comment

Choose a reason for hiding this comment

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

Will do some tests myself today. Will merge if nothing stands out.

The hypervisor prevention part is a bit concerning though, most of the large jobs are run under VMs.

/* Detect CPU cache sizes at runtime using CPUID.
* CPUID cannot be used reliably on:
* 1. non-x86_64 architectures
* 2. virtualized environments (CPUID may report incorrect cache sizes)
Copy link
Member

Choose a reason for hiding this comment

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

May I ask, does this pretty much rule out most of cloud instances?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yes :(
but we don't have any good way to find real cache sizes in this case.

@trivialfis
Copy link
Member

@razdoburdin I shared a WIP benchmark result with your GitHub email address [email protected], please take a look when you are available. I highlighted the regression cases.

@razdoburdin
Copy link
Contributor Author

razdoburdin commented Nov 20, 2025

@razdoburdin I shared a WIP benchmark result with your GitHub email address [email protected], please take a look when you are available. I highlighted the regression cases.

haven't received them. Could you please duplicate to [email protected] ?

@trivialfis
Copy link
Member

CPU l1 hist.csv
I will upload a CSV instead. ;-(

@razdoburdin
Copy link
Contributor Author

CPU l1 hist.csv I will upload a CSV instead. ;-(

Could you also share HW details? Is it a virtualized environment? ARM or x86?

@trivialfis
Copy link
Member

Could you also share HW details? Is it a virtualized environment? ARM or x86?

Just my personal desktop:

  • AMD Ryzen 9 7900X3D 12-Core Processor. I can find an Intel server if needed, but I need to use machines from work.
  • Bare metal. No VM or container.

@razdoburdin
Copy link
Contributor Author

  • AMD Ryzen 9 7900X3D 12-Core Processor. I can find an Intel server if needed, but I need to use machines from work.
  • Bare metal. No VM or container.

I see. 7900X3D has great L3 per core capacity, I didn't take into account before. I have upgraded the code.

@trivialfis
Copy link
Member

branch n_samples_per_batch n_features n_batches sparsity size (GB) max_bin n_rounds rmse DMatrix-Train Train
L1 hist 1048576 245 32 0 30.625 257 128 14.84709263 89.5329814 438.3597314
L1 hist + L3 1048576 245 32 0 30.625 257 128 14.84709263 87.28941536 422.529346
master 1048576 245 32 0 30.625 257 128 14.84709263 89.73098493 284.3891058

@razdoburdin razdoburdin marked this pull request as draft November 20, 2025 15:36
@razdoburdin
Copy link
Contributor Author

branch n_samples_per_batch n_features n_batches sparsity size (GB) max_bin n_rounds rmse DMatrix-Train Train
L1 hist 1048576 245 32 0 30.625 257 128 14.84709263 89.5329814 438.3597314
L1 hist + L3 1048576 245 32 0 30.625 257 128 14.84709263 87.28941536 422.529346
master 1048576 245 32 0 30.625 257 128 14.84709263 89.73098493 284.3891058

ok, i hope I have found the reason.
AMD stores the topology in another leaf. I have added the vendor switch. I have also removed under_hypervisor flag, as far as current realization automatically fallback to default values if virtualized environment is unable to report cache size.

Unfortunately I don't have any 7900X3D to verify the perf by myself.

@razdoburdin razdoburdin marked this pull request as ready for review November 20, 2025 16:27
@trivialfis
Copy link
Member

trivialfis commented Nov 20, 2025

Excellent! I can confirm that the regression is now fixed. Would you like to share your benchmark results for the datasets that you have tested?

I will run some more tests to deliberately disable cpuid (hence using the default value), just in case there's a regression for cloud users.

as far as current realization automatically fallback to default values if virtualized environment is unable to report cache size.

Could you please elaborate on how the current code detects the case and performs fallback? Is it guaranteed that under VM, if (type == kCpuidTypeNull) break is true? Asking since previously there was an explicit check for VM; now that the check is gone, it looks like the loop will finish all the way down to the bottom cache level. (I should probably create a VM to test this ....)

@trivialfis
Copy link
Member

trivialfis commented Nov 22, 2025

Hi, I will be on holiday next week. Response might be slow.

I don't want to make this PR difficult, and the CPU implementation could benefit greatly from optimizations. Please feel free to create specialized code for targeted sets of CPUs, make sure the specialization is well-scoped, say within 20 lines of code, and doesn't regress other CPUs.

@razdoburdin
Copy link
Contributor Author

Excellent! I can confirm that the regression is now fixed. Would you like to share your benchmark results for the datasets that you have tested?

here are benchmarks results, I have made on my 56-cores machine. epsilon is the only case with ColWiseHistBuild kernel in use.
image

@razdoburdin
Copy link
Contributor Author

Could you please elaborate on how the current code detects the case and performs fallback? Is it guaranteed that under VM, if (type == kCpuidTypeNull) break is true? Asking since previously there was an explicit check for VM; now that the check is gone, it looks like the loop will finish all the way down to the bottom cache level. (I should probably create a VM to test this ....)

All cache sizes are initialized by -1 by default.
In case some cache level doesn't exist (or VM is configured not to report it) the condition (type == kCpuidTypeNull) would break the execution and some elements of cache_size array would still be equal to -1. The getters in CacheManager class check if the corresponding element is -1 and return the default value in these case.

@razdoburdin
Copy link
Contributor Author

hi @trivialfis,

what is your opinion about this?

@trivialfis
Copy link
Member

trivialfis commented Dec 2, 2025

Running some tests (picking up from the last week). Out of curiosity, is the Linux /sys/devices/system/cpu/cpu0/cache/index2/size a reliable source of information? In addition, what happens to CPUs with efficient/performance cores, or what happens with CPUs that have different dies (for example, amd 3d cache)?

@razdoburdin
Copy link
Contributor Author

Out of curiosity, is the Linux /sys/devices/system/cpu/cpu0/cache/index2/size a reliable source of information?

In the best of my understanding it utilizes similar to cpuid by kernel loading, so the results should be the same if the kernel is new.

In addition, what happens to CPUs with efficient/performance cores

cpuid would report L1/L2 corresponding to the logical core, that executes the cpuid. So not 100% optimal values for all cores.

, or what happens with CPUs that have different dies (for example, amd 3d cache)?

it would report a total L3 size per CPU (for example in case of 2-dies with 16MB each, the reported value would be 32MB).

@trivialfis
Copy link
Member

@razdoburdin Could you please help take a look into the sycl error? Seems broken by dependency updates.

@razdoburdin
Copy link
Contributor Author

@razdoburdin Could you please help take a look into the sycl error? Seems broken by dependency updates.

yes, I will take a look

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.

3 participants