cd /news/artificial-intelligence/blackwell-brings-numa-back-inside-on… · home › topics › artificial-intelligence › article
[ARTICLE · art-157775] src=vincentzed.com ↗ pub= topic=artificial-intelligence verified=true sentiment=· neutral

Blackwell Brings NUMA Back, Inside One GPU

DeepSeek addressed the dual-die memory locality of Nvidia's Blackwell (SM100) B200 and B300 GPUs in a pull request to its DeepGEMM library (deepseek-ai/DeepGEMM#462), splitting MoE weights along the N dimension so each of the two dies joined by NV-HBI stores its own half in local HBM. Because CUDA exposes no API identifying which SM sits on which die, DeepGEMM measures it at startup with a pointer-chasing probe timed via clock64, accepting the result only if the far die is at least 1.25x slower and both SMs in every TPC agree, retrying up to 5 times before falling back to an even split. The code allocates die-local memory through MLOPart, an MPS mode, using cuMemAddressReserve and cuMemMap, pending direct per-die allocation that the post says should arrive in CUDA 13.4.

by read6 min views2 publishedOct 26, 2026
Blackwell Brings NUMA Back, Inside One GPU
Image: source

On Blackwell (SM100) we are back to solving NUMA problems, inside a single GPU package.

To software, a B200 or B300 looks like one big GPU. CUDA shows one device with a flat address space and a unified L2, and you launch kernels across all the SMs just like you did on Hopper. Physically, though, it is two separate silicon dies joined by NV-HBI. The link is fast, but HBM attached to your own die is still noticeably closer than HBM attached to the other one. If you ignore this, memory-heavy kernels like MoE layers pull half their weights across the link, which costs latency and power.

DeepSeek recently handled this in DeepGEMM for their MegaMoE kernel (deepseek-ai/DeepGEMM#462). All the code below comes from that implementation.

Two locality domains# #

Each die is a locality domain with its own SMs and its own HBM stacks. CUDA has no API that tells you which SM sits on which die, so DeepGEMM measures it at startup. It puts one buffer on each die and has every SM chase a chain of pointers through both, timing the hops with clock64:

template <uint32_t kNumHops, uint32_t kNumChunksPerSM, uint32_t kNumChunkBytes>
__global__ __launch_bounds__(32, 1)
void locality_domain_probe_chase(const uint32_t* buf, uint16_t* out) {
    if (threadIdx.x != 0)
        return;
    const auto sm_idx = ptx::get_sm_idx();
    for (uint32_t chunk_idx = 0; chunk_idx < kNumChunksPerSM; ++ chunk_idx) {
        const auto chunk = buf + static_cast<uint64_t>(sm_idx * kNumChunksPerSM + chunk_idx) * (kNumChunkBytes / 4);
        uint32_t idx = ptx::ld_global_cg(chunk);
        const auto start = clock64();
        #pragma unroll
        for (uint32_t i = 0; i < kNumHops; ++ i)
            idx = ptx::ld_global_cg(chunk + idx);
        const auto cycles = clock64() - start;
        out[sm_idx * kNumChunksPerSM + chunk_idx] = idx == 0xffffffffu ? 0u : static_cast<uint16_t>(cycles / kNumHops);
    }
}

The probe launches one 32-thread block per SM, and each block reserves the full capacity of shared memory so no SM can run two of them at once. Only thread 0 does the chase. Each 4 KB chunk is 32 cache lines, and each line points 7 lines ahead, so every load waits on the one before it. The loads use ld.global.cg to bypass L1, and L2 is flushed beforehand, so the cycles measure HBM round-trip latency.

Each SM is assigned to whichever die it reads faster, taking the median over 8 chunks. The measurement is only accepted if the far die is at least 1.25x slower across the board and both SMs in every TPC agree. The two SMs in a TPC are physically paired, so disagreement means the measurement is noise. DeepGEMM retries up to 5 times and otherwise falls back to an even split. And because the scheduler expects an exact 50-50 balance, asymmetry from harvested SMs is fixed by reassigning a few TPCs to the smaller side.

Slicing the weights across dies# #

Once you know where each SM lives, the next problem is memory placement. Instead of storing MoE weights as one big flat array, MegaMoE splits each tensor along N. So (*B, N, K) becomes (2, *B, N/2, K), with slice d stored in die d’s HBM:

def localize(t: torch.Tensor, dim: int = -2) -> torch.Tensor:
    num_domains, dim = get_num_locality_domains(), dim % t.dim()
    assert t.size(dim) % num_domains == 0
    localized = empty_per_domain((*t.shape[:dim], t.size(dim) // num_domains, *t.shape[dim + 1:]), t.dtype)
    localized.copy_(t.unflatten(dim, (num_domains, -1)).movedim(dim, 0))
    return localized

Behind the scenes, empty_per_domain reserves one virtual address range and maps each half onto physical memory on its own die using the CUDA virtual memory APIs (cuMemAddressReserve, cuMemMap). CUDA cannot allocate on a specific die directly yet—that should arrive in CUDA 13.4. Until then, DeepGEMM gets die-local memory through MLOPart, an MPS mode that exposes each die as a separate CUDA device. DeepGEMM spins up a private MPS server in a helper process, does the allocation there, and passes each chunk back to the main process as a shareable file descriptor.

Affinity scheduling and work stealing# #

Having localized data only helps if your thread blocks run on the right SMs. MegaMoE uses a persistent scheduler where each die gets its own task counter. A task pulled from die d’s counter maps to die d’s half of N, so by default an SM reads weights only from its own HBM. When its own queue runs dry, an SM can steal from the other die, but only if the layer has at least two waves of work. For smaller layers, stealing is skipped entirely:

template <uint32_t kNumClusters>
static CUTLASS_DEVICE bool get_next_task_idx(const uint32_t* task_count_ptr, const uint32_t& locality_domain_idx, const uint32_t& num_m_blocks,
                                             uint32_t& m_block_idx, uint32_t& n_cluster_idx) {
    constexpr uint32_t kNumLocalClusters = kNumClusters / kNumLocalityDomains;
    // One lane does an atomic add on this die's counter and broadcasts the result
    const uint32_t task_idx = get_next_local_task_idx(task_count_ptr, locality_domain_idx);
    if (task_idx >= num_m_blocks * kNumLocalClusters)
        return false;
    m_block_idx = task_idx / kNumLocalClusters;
    // Clusters [d * kNumLocalClusters, (d + 1) * kNumLocalClusters) cover die d's half of N
    n_cluster_idx = locality_domain_idx * kNumLocalClusters + task_idx % kNumLocalClusters;
    return true;
}

template <uint32_t kNumClusters>
CUTLASS_DEVICE bool get_next_task_idx(const uint32_t* task_count_ptr, const uint32_t& num_m_blocks,
                                      uint32_t& m_block_idx, uint32_t& n_cluster_idx) const {
    // 1. This SM's own die first
    if (get_next_task_idx<kNumClusters>(task_count_ptr, sm_locality_domain_idx, num_m_blocks, m_block_idx, n_cluster_idx))
        return true;
    // 2. Steal from the other die only with 2+ waves of work (one wave = kNumSMs / 2 two-SM clusters)
    return kNumLocalityDomains > 1 and num_m_blocks * kNumClusters >= kNumMinStealWaves * (kNumSMs / 2) and
           get_next_task_idx<kNumClusters>(task_count_ptr, sm_locality_domain_idx ^ 1, num_m_blocks, m_block_idx, n_cluster_idx);
}

Stealing across dies means higher memory latency and a busier link. For a few leftover tiles it is faster to let the local die finish them; with two or more waves of work, the steal pays for its penalty.

Hardware addressing with TMA# #

The die split is also encoded in the Tensor Memory Accelerator (TMA) descriptor. The weight tensor map is 4D, and one dimension is the die index: [K, N/2, die, expert]. Instead of pointer math, each CTA picks the half with a coordinate:

// Each CTA of the 2-SM cluster loads its own BLOCK_N slice
const auto n_block_idx = task_info.n_cluster_idx * 2 + (is_leader_cta ? 0u : 1u);
// half_idx: which die's slice holds this N block
const uint32_t half_n = shape_n / 2, n_idx = n_block_idx * BLOCK_N, half_idx = n_idx >= half_n;
const uint32_t half_n_idx = n_idx - half_idx * half_n, expert_idx = task_info.is_shared() ? 0 : task_info.local_expert_idx;

// TMA coordinates: [k, n within the half, die, expert]
tma::copy_nd<BLOCK_K, LOAD_BLOCK_N, kSwizzleBMode, weight_dtype_t, 4>(
    tensor_map_b_ptr, &shared_storage.full_barriers[stage_idx], shared_storage.smem_b[stage_idx],
    2,  // 2-CTA load: completion is signaled to the leader CTA's barrier
    k_idx, half_n_idx, half_idx, expert_idx);

The stride along the die dimension steps directly across the physical split in memory, so half_idx decides which die serves the load. Normally that coordinate matches the SM’s physical die, so traffic stays local. Only stolen tasks, or SMs on reassigned TPCs, read across NV-HBI. The inner loop needs no branches or address recalculation.

DeepSeek’s release notes mention this saves about 100 W of package power and gives 5-10% higher GPU clocks under heavy load.

As chips scale by stitching dies together, the hardware gets less uniform, not more. Treating one GPU as two locality domains takes more engineering up front, and it pays for itself.

── more in #artificial-intelligence 4 stories · sorted by recency
── more on @deepseek 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/blackwell-brings-num…] indexed:0 read:6min 2026-10-26 · —