Skip to content

Commit cc2f48c

Browse files
authored
Fix MT read memory limit defaulting to wrong size when off-heap limit is disabled (#14507)
Fixes #14292. ### Description When `spark.rapids.memory.host.offHeapLimit.enabled` is false (the default), `getPinnedPoolAndOffHeapLimits` returns `-1` for the non-pinned limit (meaning unlimited). The code that sets the default MT read memory limit was using this `-1` literally: ```scala (0.9 * (pinnedSize + nonPinnedLimit)).toLong // nonPinnedLimit == -1 ``` With no pinned pool this produces `0`; with a pinned pool (e.g. 2 GiB) it produces `~0.9 * pinnedSize`, sizing the limit off the pinned pool only and ignoring all non-pinned memory. The fix extracts the hardware-derived off-heap limit computation from `getPinnedPoolAndOffHeapLimits` into a shared `computeEffectiveOffHeapLimit` method, and uses it in `computeMtReadLimit` when `nonPinnedLimit == -1` to derive a concrete total from hardware instead. Note: `spark.rapids.sql.multiThreadedRead.memoryLimit.enabled` defaults to `false`, so impact is limited to users who have explicitly enabled the feature. The hardware-derived calculation has a known approximation limitation in certain environments (see #13628), called out in a new code comment. **Before (2 GiB pinned pool, off-heap limit disabled):** MT read limit ≈ 1.80 GiB (90% of pinned only) **After:** MT read limit ≈ 78 GiB (90% of hardware-derived off-heap) ### Checklists - [ ] This PR has added documentation for new or modified features or behaviors. - [x] This PR has added new tests or modified existing tests to cover new code paths. `GpuDeviceManagerSuite` — "MT read limit should be 90% of total off heap when off heap limit disabled": calls `computeMtReadLimit` with `nonPinnedLimit=-1` and asserts the result equals 90% of the hardware-derived off-heap total. Fails against pre-fix code, passes after. - [ ] Performance testing has been performed and its results are added in the PR description. Or, an issue has been filed with a link in the PR description. --------- Signed-off-by: Zach Puller <zpuller@nvidia.com>
1 parent 14f47f8 commit cc2f48c

2 files changed

Lines changed: 132 additions & 67 deletions

File tree

sql-plugin/src/main/scala/com/nvidia/spark/rapids/GpuDeviceManager.scala

Lines changed: 105 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -481,23 +481,43 @@ object GpuDeviceManager extends Logging {
481481
}
482482
// Host memory limits must be set after the pinned memory pool is initialized
483483
HostAlloc.initialize(nonPinnedLimit)
484-
// Fill the MULTITHREAD_READ_MEMORY_LIMIT_SIZE with the 90% of the total OFF_HEAP memory
485-
// if it is not set already.
484+
// Fill the MULTITHREAD_READ_MEMORY_LIMIT_SIZE with 90% of the total off-heap memory
485+
// if it is not set already. When off-heap limit tracking is disabled (nonPinnedLimit == -1),
486+
// falls back to a hardware-derived estimate via computeEffectiveOffHeapLimit.
486487
if (conf.multiThreadReadMemoryLimit == 0) {
487488
sparkConf.set(RapidsConf.MULTITHREAD_READ_MEMORY_LIMIT_SIZE.key,
488-
(0.9 * (pinnedSize + nonPinnedLimit)).toLong.toString)
489+
computeMtReadLimit(pinnedSize, nonPinnedLimit, conf, sparkConf,
490+
Cuda.getDeviceCount).toString)
489491
}
490492
}
491493

492494
// visible for testing
493-
def getPinnedPoolAndOffHeapLimits(conf: RapidsConf, sparkConf: SparkConf, deviceCount: Int,
494-
memCheck: MemoryChecker =
495-
MemoryCheckerImpl): (Long, Long) = {
496-
val perTaskOverhead = conf.perTaskOverhead
497-
val totalOverhead = perTaskOverhead * GpuDeviceManager.numCores
498-
val confPinnedSize = conf.pinnedPoolSize
495+
def computeMtReadLimit(pinnedSize: Long, nonPinnedLimit: Long,
496+
conf: RapidsConf, sparkConf: SparkConf, deviceCount: Int,
497+
memCheck: MemoryChecker = MemoryCheckerImpl): Long = {
498+
val totalOffHeap = if (nonPinnedLimit >= 0) {
499+
pinnedSize + nonPinnedLimit
500+
} else {
501+
// nonPinnedLimit == -1 means off-heap limit tracking is disabled (unlimited).
502+
// Derive a concrete total from hardware so the MT read limit is still meaningful.
503+
// Note: this feature is off by default
504+
// (spark.rapids.sql.multiThreadedRead.memoryLimit.enabled defaults to false), so the risk
505+
// of this approximation affecting users is low. The
506+
// underlying hardware-derived calculation has known limitations — in particular, it may not
507+
// account correctly for spark.memory.offHeap.size in all environments (see
508+
// https://github.com/NVIDIA/spark-rapids/issues/13628). For now we reuse the same
509+
// best-effort estimate rather than special-casing this path.
510+
computeEffectiveOffHeapLimit(conf, sparkConf, deviceCount, memCheck)
511+
}
512+
val limit = (0.9 * totalOffHeap).toLong
513+
logInfo(s"Setting MT read memory limit to ${limit / 1024 / 1024.0} MiB " +
514+
s"(90% of ${totalOffHeap / 1024 / 1024.0} MiB total off-heap)")
515+
limit
516+
}
517+
518+
private def computeEffectiveOffHeapLimit(conf: RapidsConf, sparkConf: SparkConf,
519+
deviceCount: Int, memCheck: MemoryChecker): Long = {
499520
val confLimit = conf.offHeapLimit
500-
val confLimitEnabled = conf.offHeapLimitEnabled
501521
// This min limit of 4GB is somewhat arbitrary, but based on some testing which showed
502522
// that the previous minimum of 15 MB * num cores was too little for certain benchmark
503523
// queries to complete, whereas this limit was sufficient.
@@ -521,68 +541,87 @@ object GpuDeviceManager extends Logging {
521541
0L
522542
}
523543

524-
if (confLimitEnabled) {
525-
val memoryLimit = if (confLimit.isDefined) {
526-
if (executorOverhead.isEmpty) {
527-
logWarning(s"$executorOverheadKey is not set")
528-
}
529-
logInfo(s"using configured ${RapidsConf.OFF_HEAP_LIMIT_SIZE} of ${confLimit.get}")
530-
confLimit.get
544+
if (confLimit.isDefined) {
545+
if (executorOverhead.isEmpty) {
546+
logWarning(s"$executorOverheadKey is not set")
547+
}
548+
logInfo(s"using configured ${RapidsConf.OFF_HEAP_LIMIT_SIZE} of ${confLimit.get}")
549+
confLimit.get
550+
} else {
551+
// in case we cannot query the host for available memory due to environmental
552+
// constraints, getAvailableMemoryBytes returns None and we fall back to 0, which causes
553+
// basedOnHostMemory to go negative and trip the minMemoryLimit floor below.
554+
lazy val availableHostMemory = if (isIntegratedGpu) {
555+
(memCheck.getAvailableMemoryBytes(conf).getOrElse(0L) * (1.0 -
556+
conf.integratedGpuMemoryFraction)).toLong
531557
} else {
532-
// in case we cannot query the host for available memory due to environmental
533-
// constraints, we can fall back to minMemoryLimit via saying there's no available
534-
lazy val availableHostMemory = if (isIntegratedGpu) {
535-
(memCheck.getAvailableMemoryBytes(conf).getOrElse(0L) * (1.0 -
536-
conf.integratedGpuMemoryFraction)).toLong
558+
memCheck.getAvailableMemoryBytes(conf).getOrElse(0L)
559+
}
560+
val hostMemUsageFraction = .8
561+
// Spark calculates the total mem to allocate to the job as
562+
// val totalMemMiB =
563+
// executorMemoryMiB + memoryOverheadMiB + memoryOffHeapMiB + pysparkMemToUseMiB
564+
// and RAPIDS uses memory from the overhead portion here. Therefore, if the overhead is
565+
// set we can just use that, otherwise we can infer it from the above as
566+
// val memoryOverheadMiB =
567+
// totalMemMiB - executorMemoryMiB - memoryOffHeapMiB - pysparkMemToUseMiB
568+
// where totalMemMiB is instead derived from the actual mem limits we can observe
569+
// directly from the system.
570+
// Note: subtracting sparkOffHeapSize here is an approximation. In some environments
571+
// (e.g. Databricks) spark.memory.offHeap.size is set to a large value and RAPIDS is
572+
// expected to operate within that allocation rather than outside it, so subtracting it
573+
// double-counts the memory and under-estimates the available limit. Fixing this properly
574+
// may require hooking into Spark's MemoryManager. See
575+
// https://github.com/NVIDIA/spark-rapids/issues/13628
576+
lazy val basedOnHostMemory = (hostMemUsageFraction * ((1.0 * availableHostMemory /
577+
deviceCount) - heapSize - pysparkOverhead - sparkOffHeapSize)).toLong
578+
if (executorOverhead.isDefined) {
579+
val basedOnConfiguredOverhead = executorOverhead.get
580+
logWarning(s"${RapidsConf.OFF_HEAP_LIMIT_SIZE} is not set; we derived " +
581+
s"a memory limit from ($executorOverheadKey = ${executorOverhead.get}")
582+
if (basedOnConfiguredOverhead < minMemoryLimit) {
583+
logWarning(s"memory limit $basedOnConfiguredOverhead is less than the minimum of " +
584+
s"$minMemoryLimit; using the latter")
585+
if (minMemoryLimit > basedOnHostMemory) {
586+
logWarning(s"the amount of available memory detected on the host is " +
587+
s"$availableHostMemory, based off of which we computed a limit of " +
588+
s"$basedOnHostMemory, which is less than the minimum $minMemoryLimit, " +
589+
s"so we are using the minimum $minMemoryLimit")
590+
}
591+
minMemoryLimit
537592
} else {
538-
memCheck.getAvailableMemoryBytes(conf).getOrElse(0L)
593+
basedOnConfiguredOverhead
539594
}
540-
val hostMemUsageFraction = .8
541-
// Spark calculates the total mem to allocate to the job as
542-
// val totalMemMiB =
543-
// executorMemoryMiB + memoryOverheadMiB + memoryOffHeapMiB + pysparkMemToUseMiB
544-
// and RAPIDS uses memory from the overhead portion here. Therefore, if the overhead is
545-
// set we can just use that, otherwise we can infer it from the above as
546-
// val memoryOverheadMiB =
547-
// totalMemMiB - executorMemoryMiB - memoryOffHeapMiB - pysparkMemToUseMiB
548-
// where totalMemMiB is instead derived from the actual mem limits we can observe
549-
// directly from the system
550-
lazy val basedOnHostMemory = (hostMemUsageFraction * ((1.0 * availableHostMemory /
551-
deviceCount) - heapSize - pysparkOverhead - sparkOffHeapSize)).toLong
552-
if (executorOverhead.isDefined) {
553-
val basedOnConfiguredOverhead = executorOverhead.get
554-
logWarning(s"${RapidsConf.OFF_HEAP_LIMIT_SIZE} is not set; we derived " +
555-
s"a memory limit from ($executorOverheadKey = ${executorOverhead.get}")
556-
if (basedOnConfiguredOverhead < minMemoryLimit) {
557-
logWarning(s"memory limit $basedOnConfiguredOverhead is less than the minimum of " +
558-
s"$minMemoryLimit; using the latter")
559-
if (minMemoryLimit > basedOnHostMemory) {
560-
logWarning(s"the amount of available memory detected on the host is " +
561-
s"$availableHostMemory, based off of which we computed a limit of " +
562-
s"$basedOnHostMemory, which is less than the minimum $minMemoryLimit, " +
563-
s"so we are using the minimum $minMemoryLimit")
564-
}
565-
minMemoryLimit
566-
} else {
567-
basedOnConfiguredOverhead
568-
}
595+
} else {
596+
logWarning(s"${RapidsConf.OFF_HEAP_LIMIT_SIZE} is not set; we used " +
597+
s"memory limit derived from ($hostMemUsageFraction * (estimated available " +
598+
s"host memory / device count) - $heapSizeKey - $pysparkOverheadKey - " +
599+
s"$sparkOffHeapSizeKey) = ($hostMemUsageFraction * ($availableHostMemory / " +
600+
s"$deviceCount) - $heapSize - $pysparkOverhead - $sparkOffHeapSize) = " +
601+
s"$basedOnHostMemory")
602+
if (basedOnHostMemory < minMemoryLimit) {
603+
logWarning(s"the memory limit, $basedOnHostMemory, based on the available " +
604+
s"host memory of $availableHostMemory, is less than the minimum of " +
605+
s"$minMemoryLimit; using the latter $minMemoryLimit")
606+
minMemoryLimit
569607
} else {
570-
logWarning(s"${RapidsConf.OFF_HEAP_LIMIT_SIZE} is not set; we used " +
571-
s"memory limit derived from ($hostMemUsageFraction * (estimated available " +
572-
s"host memory / device count) - $heapSizeKey - $pysparkOverheadKey - " +
573-
s"$sparkOffHeapSizeKey) = ($hostMemUsageFraction * ($availableHostMemory / " +
574-
s"$deviceCount) - $heapSize - $pysparkOverhead - $sparkOffHeapSize) = " +
575-
s"$basedOnHostMemory")
576-
if (basedOnHostMemory < minMemoryLimit) {
577-
logWarning(s"the memory limit, $basedOnHostMemory, based on the available " +
578-
s"host memory of $availableHostMemory, is less than the minimum of " +
579-
s"$minMemoryLimit; using the latter $minMemoryLimit")
580-
minMemoryLimit
581-
} else {
582-
basedOnHostMemory
583-
}
608+
basedOnHostMemory
584609
}
585610
}
611+
}
612+
}
613+
614+
// visible for testing
615+
def getPinnedPoolAndOffHeapLimits(conf: RapidsConf, sparkConf: SparkConf, deviceCount: Int,
616+
memCheck: MemoryChecker =
617+
MemoryCheckerImpl): (Long, Long) = {
618+
val perTaskOverhead = conf.perTaskOverhead
619+
val totalOverhead = perTaskOverhead * GpuDeviceManager.numCores
620+
val confPinnedSize = conf.pinnedPoolSize
621+
val confLimitEnabled = conf.offHeapLimitEnabled
622+
623+
if (confLimitEnabled) {
624+
val memoryLimit = computeEffectiveOffHeapLimit(conf, sparkConf, deviceCount, memCheck)
586625

587626
// Now we need to know the pinned vs non-pinned limits
588627
val pinnedLimit = if (confPinnedSize + totalOverhead <= memoryLimit) {

tests/src/test/scala/com/nvidia/spark/rapids/GpuDeviceManagerSuite.scala

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2020-2025, NVIDIA CORPORATION.
2+
* Copyright (c) 2020-2026, NVIDIA CORPORATION.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -437,6 +437,32 @@ class GpuDeviceManagerSuite extends AnyFunSuite with BeforeAndAfter {
437437
}
438438
}
439439

440+
test("MT read limit should be 90% of total off heap when off heap limit disabled") {
441+
val deviceCount = 1
442+
val availableHostMem = 32L * 1024 * 1024 * 1024 // 32 GiB
443+
val heapSize = toBytes("1g") // default spark.executor.memory
444+
445+
val sparkConf = new SparkConf()
446+
val rapidsConf = new RapidsConf(Map(
447+
RapidsConf.OFF_HEAP_LIMIT_ENABLED.key -> "false"))
448+
449+
TestMemoryChecker.setAvailableMemoryBytes(Some(availableHostMem))
450+
451+
try {
452+
val mtLimit = GpuDeviceManager.computeMtReadLimit(
453+
pinnedSize = 0L, nonPinnedLimit = -1L,
454+
rapidsConf, sparkConf, deviceCount, TestMemoryChecker)
455+
456+
// When off-heap limit is disabled, the effective total off-heap should be derived
457+
// from hardware (same 80% formula used in the enabled path), and MT read limit = 90% of that.
458+
val effectiveOffHeap = (0.8 * (availableHostMem - heapSize)).toLong
459+
val expectedMtLimit = (0.9 * effectiveOffHeap).toLong
460+
assertResult(expectedMtLimit)(mtLimit)
461+
} finally {
462+
TestMemoryChecker.setAvailableMemoryBytes(None)
463+
}
464+
}
465+
440466
test("get host memory limits with discrete GPU") {
441467
val deviceCount = 1
442468
val pySparkOverheadStr = "2g"

0 commit comments

Comments
 (0)