Skip to content

Use the target's real cache line size in the hist builder - #12453

Open
bryceweiner wants to merge 2 commits into
dmlc:masterfrom
bryceweiner:apple-silicon-cache-line
Open

Use the target's real cache line size in the hist builder#12453
bryceweiner wants to merge 2 commits into
dmlc:masterfrom
bryceweiner:apple-silicon-cache-line

Conversation

@bryceweiner

Copy link
Copy Markdown
Contributor

The CPU histogram builder hardcodes a 64-byte cache line in two places:

  • src/common/hist_util.ccPrefetch::kCacheLineSize, which sets the prefetch
    stride and the size of the no-prefetch tail.
  • src/tree/hist/histogram.h — a local kCacheLineSize used to floor the block
    size, with a comment stating its purpose is "to ensure a full cache line is
    utilized when loading gradient pairs".

Apple Silicon uses 128-byte lines (sysctl hw.cachelinesize reports 128). On
that target both constants work against their own stated intent: the prefetch
loop issues two prefetches per line instead of one, and the minimum block covers
half a line, so neighbouring blocks handed to different threads can share one.

This replaces both with a single common::kCacheLineSize, placed in
cache_manager.h next to the other cache facts and selected per target.

Why not std::hardware_destructive_interference_size

That would be the idiomatic way to express this, and it is not usable here.
libc++ reports 64 for it on arm64, including Apple Silicon, so it would
reintroduce the exact value this constant exists to correct. Verified rather
than assumed:

$ clang++ -std=c++17 hdis.cc && ./a.out     # Apple clang 17, arm64
__cpp_lib_hardware_interference_size = 201703
destructive  = 64
constructive = 64

Verification

The two compile-time properties — plausibility and power-of-two-ness — are
checked with static_assert rather than at runtime, since the compiler can
prove both. A separate test asserts the compiled constant against
hw.cachelinesize, so a wrong value fails loudly instead of silently mistuning
the build:

  • CacheManager.AppleSiliconCacheLineSize — compiled constant vs. what the
    machine reports.

Performance

This is a correctness fix, not a measured speedup, and I want to be explicit
about that.
Across 27 interleaved training runs on an M4 Max — row-wise,
column-wise and sparse shapes, with rotated ordering — it made no difference
outside noise in either direction. The minimum block size rarely binds in
practice, because block_size is normally in the hundreds of rows.

It is worth having because the constant is simply wrong for the target, and
because the false sharing it permits is a function of thread count and partition
layout rather than of the three shapes I happened to measure.

Compatibility

No behaviour change on any other target — kCacheLineSize is 64 there, exactly
as before. 745 C++ unit tests pass on macOS arm64, and the x86_64 branch was
cross-compiled to confirm it still builds.

Note

Stacks on (Detect CPU cache sizes on Apple Silicon), which introduces the
new contents of cache_manager.h. Please merge that one first.

`CacheManager` detects cache sizes with CPUID on x86_64 and sysfs on non-x86_64
Linux, and falls back to compiled defaults everywhere else. macOS on arm64 hits
the fallback, so XGBoost tunes its CPU histogram build against a 32KB L1 and a
1MB L2 regardless of the hardware it is running on.

Add a sysctl-based path for Apple Silicon. Two details differ from x86 and shape
the implementation:

- Cores are heterogeneous, so sizes are read per performance level and the
  minimum is taken. Threads are spread over both core types, and the header
  already notes that overestimating a cache costs more than underestimating it:
  a block sized for a performance core's L1 would spill on an efficiency core.
- L2 is shared by every core in a cluster rather than being private per core, so
  the raw `hw.perflevelN.l2cachesize` is not what a single thread can rely on.
  Dividing by `cpusperl2` restores the per-thread meaning that the callers in
  hist_util.cc and tree/hist/histogram.h assume. A level only contributes an L2
  estimate when its sharing factor is known, since an undivided cluster size
  would overestimate the budget several-fold.

The policy that reduces per-level readings to per-thread sizes is kept separate
from the sysctl query, in ReduceHeterogeneousCaches, and is compiled on every
platform rather than inside the Apple branch. Deciding what a thread can rely on
across heterogeneous cores is a judgement call, and welding it to sysctl would
have left every branch except the one the developer's machine happens to take
untested. It is now covered directly with synthetic readings: one homogeneous
level, a two-level performance/efficiency split in both orders, a level with an
unknown sharing factor, absent and non-positive readings, and a sharing factor
larger than the cluster.

That last case is why the separation was worth it. A cluster smaller than its
sharing factor divides to zero, and zero would then have been stored as though
it were a detected size, so callers would divide by it rather than fall back to
the default. The reduction discards a non-positive result.

The query returns std::optional rather than a sentinel, so there is no -1 to
keep in sync with CacheManager::kUninitCache, which is private to the class. A
sysctl value that would overflow int64 is rejected rather than wrapping.

The Apple system level cache is not exposed through sysctl, so L3 is left unset
and keeps its default of 0. Intel Macs are unaffected: they still take the
CPUID path, which is checked first.

On an M4 Max (12P+4E) this raises the detected L1d from the compiled 32KB
default to the 64KB the efficiency cores report. The derived per-thread L2 works
out to exactly the 1MB default on that part, so L1 is the only value that
changes there, but both are now read from the hardware rather than guessed.

The L1 value feeds block_size in ConstructHistSpace, which only matters on the
read_by_column path that dense data takes once the histogram outgrows the L2
budget (above roughly 200 features at the default max_bin):

    usable_l1 = 0.8 * L1;  space = usable_l1 - 2 * sizeof(GradientPairPrecise) * max_bin
    block_size = space / (sizeof(GradientPair) + 3 * sizeof(size_t))

The size of the gain therefore depends on max_bin, since the per-column
histogram is subtracted from the L1 budget before rows get the remainder. On an
M4 Max, interleaved runs with rotated ordering, reporting the minimum and first
quartile because the maximum is dominated by unrelated system activity:

    max_bin=512  500K x 400 dense  30 rounds  6.141 -> 4.743  (-22.8% min)
    max_bin=256  400K x 300 dense  25 rounds  1.362 -> 1.296  ( -4.9% min)
    max_bin=256  400K x 400 dense  25 rounds  1.761 -> 1.694  ( -3.8% min)

So a few percent at the default max_bin, growing with max_bin. Shapes that stay
on the row-wise path are unaffected, as expected, since their histograms fit the
L2 budget either way and L2 does not change on this part:

    max_bin=256  1M x 50 dense     100 rounds 1.806 -> 1.811  (+0.3% min)
    max_bin=256  1M x 100 sparse   100 rounds 2.018 -> 1.999  ( -1.0% min)
    max_bin=256  400K x 150 dense   25 rounds 0.607 -> 0.618  (+1.8% min)

Also reformat cache_manager.h, which did not match clang-format.
The CPU histogram builder hardcodes a 64-byte cache line in two places: the
prefetch stride in hist_util.cc and the minimum block size in
tree/hist/histogram.h. Apple Silicon uses 128-byte lines, so on that target the
prefetch loop issues two prefetches per line instead of one, and the minimum
block covers half a line, which lets neighbouring blocks processed by different
threads share one.

Replace both with a single `common::kCacheLineSize` next to the other cache
facts, selected per target.

std::hardware_destructive_interference_size would be the obvious way to express
this, but it is not usable here: libc++ reports 64 for it on arm64 including
Apple Silicon, so it would reintroduce exactly the value this constant exists to
correct. Its plausibility and power-of-two-ness are checked with static_assert,
since both are compile-time properties, and a test asserts the compiled constant
against `hw.cachelinesize` so a wrong value fails rather than silently
mistuning the build.

This is a correctness fix, not a measured speedup. Across 27 interleaved
training runs on an M4 Max it made no difference outside noise on any of the
three shapes benchmarked, in either direction; the minimum block size rarely
binds, because block_size is normally in the hundreds of rows. It is worth
having because the constant is simply wrong for the target, and because the
false-sharing it allows is a function of thread count and partition layout
rather than of these particular shapes.

Behaviour on every other target is unchanged; kCacheLineSize is 64 there, as
before.
@trivialfis

Copy link
Copy Markdown
Member

Unfortunately, we don't have the capacity to review your optimization PRs at the moment, as none of the active maintainers use Apple silicon.

I will look into P/E cores in the future and hopefully gain some understanding of what's happening there.

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