Xbox Advanced Technology Group
Updated September 18th 2017
Appendix A: Read-speed test results
Appendix B: Write-speed test results
Optimizing to reduce memory bandwidth is a key strategy for Xbox One. We strongly recommend that, where applicable, titles consider adopting data-oriented design because this can have the single largest effect on title performance. On Xbox One and Xbox One S, ESRAM is the single most effective means of reducing DRAM contention, but it is available only to the GPU. This paper presents a number of options available to the CPU that can be leveraged to optimize title performance.
We recommend that you become familiar with the Xbox One memory-system components, available bandwidth, and coherency models before reading this white paper. See the Resources section for more information.
The Xbox One family CPU memory system support three sizes of memory page: 4 KiB, 2 MiB, and 1 GiB. Only 4-KiB and 2-MiB virtual-to-physical address translations are cached by the translation look-aside buffers (TLBs). The platform API exposes 4-KiB, 64-KiB, and 4-MiB pages, which represent the superset of the sizes supported by both the CPU and GPU. On the CPU, 4-MiB and 64-KiB pages are implemented as multiple smaller pages, 2MiB and 4KiB respectively.
Each of the pages exposed by the platform API can be one of three memory types: cacheable (default), write-combined, and non-cached. Titles are unlikely to find non-cached pages useful, so they will not be discussed further in this paper.
Loads from cacheable memory involve fetching data in aligned units of 64 bytes known as cache lines. A cache line is retired and written back to main RAM only when that cache line hasn’t been needed for some time or when another cache line needs the slot in the cache. When a function fails to make use of all bytes in a cache line, it is wasting memory bandwidth. Minimizing this waste is an important optimization strategy.
When a memory page is marked as write-combined instead of cacheable, all load/stores bypass the data cache, reducing pressure on the cache. Stores are batched into 64-byte write-gather buffers and later directly written to main RAM in bursts. There is no mechanism for accelerating loads from write-combined memory, they all are treated as cache misses and go straight to main memory. In our testing, the best speeds we have achieved is 0.045 GiB/s. Write-combined memory should only be used to write data that is not read by the CPU but is later read by either the GPU or DMA engines.
The Xbox One platform has two APIs that allow titles to allocate memory as either write-combined or cacheable and in different page sizes. These are:
XMemAlloc (through the dwMemoryType and dwPageSize members)
See XMemAlloc in the XDK documentation for more details
VirtualAlloc (through the flAllocationType and flProtect parameters)
| PAGE_READWRITE | PAGE_WRITECOMBINE |
See VirtualAlloc in the XDK documentation for more details
Each core’s bus unit (BU) maintains four 64-byte write-gather buffers used by streaming stores, non-temporal stores, or any store instruction issued to a write-combined memory page. These are used to accumulate sequential stores and write them to RAM when full.
The principal reasons to use write gather are that:
There are some important considerations to keep in mind when using write gather for maximum performance:
NOTE: Failure to do this might result in incorrect data being read.
Ensure that the compiler is not introducing a read.
a. For example, when manipulating a bit-field, if the compiler spots that a routine fails to set all bits, it must issue a read to preserve the unmodified portion of the bit-field.
Each store using write gather first attempts to append to any of the in-use write-gather buffers. If this attempt fails, the store will grab an unused buffer or, if all buffers are in use, it will flush a buffer and reuse it.
There is no mechanism for snooping data from the write-gather buffers. The operation of a load that accesses data currently waiting for write in a write-gather buffer depends on the memory type being used.
In such instances, the load-hit-store stall will be severe. The write-gather buffer must be flushed and the data reloaded from main RAM.
A single write-gather buffer is written to RAM if:
Unless a CPU thread is tightly lock-stepped with the GPU or DMA, it won’t generally be necessary to manually flush the write-gather buffers. The buffers will self-flush after 512 cycles of inactivity. However, we do recommend implementing a “paranoid” mode that performs excessive flushing and synchronization to help track down elusive glitching issues when stale data is used.
Unless a CPU thread is tightly lock-stepped with the GPU or DMA, it won’t generally be necessary to manually flush the write-gather buffers. The buffers will self-flush after 512 cycles of inactivity. However, we do recommend implementing a “paranoid” mode that performs excessive flushing and synchronization to help track down elusive glitching issues when stale data is used.
Be careful not to saturate the four write-gather buffers with more than four array accesses. Similarly, a non-sequential series of stores will cause context thrashing and severely degrade performance. For examples of timings associated with different write scenarios see Appendix B.
There are several methods that flush the write-gather buffers and wait for completion. They are:
The interlocked instructions are all implemented by using the lock prefix (sometimes called a load-op-store or read-modify-write operation). This causes all the write-gather buffers to flush before the bus and cache are locked for the duration of the instruction. These are issued by the Interlocked* API, std::atomic, critical sections, and so forth. On Xbox One, the compiler implements __faststorefence() as a lock OR instruction, performing a bitwise OR of zero with the stack pointer.
The 64-byte write-gather buffers are always written as two non-atomic 32-byte operations. Both are issued even if the buffer is half-full or less. Writes are masked, which means that memory isn’t corrupted by this process.
Streaming stores are issued by non-temporal instructions, like VMOVNT* (which corresponds to the _mm_stream_si128 intrinsic). These use the write-gather buffers even when writing to cacheable memory. Using streaming stores with cacheable memory is an important optimization strategy, halving the amount of memory bandwidth consumed. However, if the memory will be accessed within a short amount of time (512-1500 cycles), it’s generally preferable to use caching writes to avoid the cache miss penalty.
Each core of the CPU has a load store and data cache (LSDC) unit and a bus unit (BU). The LSDC is responsible for servicing all memory accesses made by the core except instruction fetch and write gather. The L1 data cache (L1DC) and translation look-aside buffers (TLBs) ensure that many memory operations can be serviced by core local resources. The bus unit is the interface between the L1 data and instruction caches and the L2 caches. In general, the BU will arbitrate in favor of data rather than instructions. The BU also maintains the write-gather buffers. The most important components of the LSDC to understand are shown in Table 1.
Table 1. Load store and data cache components.
| Component | Features | Function |
|---|---|---|
| L1 data cache (L1DC) | • 32-KiB, eight-way set associative | Caches recently accessed memory locations. |
| L1 data translation look-aside buffer (L1DTLB) | • Fully associative • 4-KiB and 2-MiB page sizes • Dual-ported • Forty-eight entries (40 4KiB, 8 2MiB) |
Caches virtual-to-physical memory translations. |
| L2 data translation look-aside buffer (L2DTLB) | • Fully associative • 4-KiB and 2-MiB page sizes • Single-ported • Xbox One and Xbox One S: 768 entries (512 4-KiB, 256 2 MiB) • Xbox One X: 2,304 entries (2048 4-KiB, 256 2-MiB) |
Caches virtual-to-physical memory translations. |
| Table walker (TWC) | Populates the TLBs on misses. | |
| L1 data prefetcher | • Eight entries | Recognizes data-cache miss patterns and issues prefetch requests ahead of time. |
| Missed-address buffer (MAB) | • Eight entries | Holds the physical addresses of L1DC misses. |
| Unified store queue (USQ) | • Twenty entries, 128 bits each | Holds all pending stores, handles blocking and store-to-load forwarding. |
The LSDC supports:
In the event of an L1DTLB and L1DC hit, the load-to-use latency for an integer is three cycles. For floating-point data, the load-to-use latency is five cycles.
The load/stores process is complex, and there are many stages that can potentially stall for a variable number of cycles, depending on many external factors.
The process is illustrated in Figure 1 (see next page).
Figure 1. Load process.
There are eight entries in the missed-address buffer (MAB). These are serviced by the BU, which requests cache lines from the L2. The MAB detects and eliminates duplicate entries. A misaligned load/store that spans a cache line requires two MAB entries to service. The MAB can only accept a single request per cycle. If both load and store requests are generated in the same cycle, up to four store requests can be deferred in favor of a load request.
The MAB does not handle write-gather operations; these operations have their own special interface to the bus.
A cache line can be out of date if:
An L1DC miss but L2 cache hit takes a minimum of 17 cycles to service. Achieving this 17-cycle latency requires that:
That is, a single MAB entry is occupied for a minimum of 17 cycles. It is most easily saturated by either excessive memory prefetching or many successive L2 and/or TLB misses.
Although the LSDC supports out-of-order loads, a guarantee is made that the data read will be temporarily correct. Consider an example in which core 0 reads the same address twice, and core 1 writes to that address:
To avoid the illegal case, when loads have been processed out of order, a cache-line refill will be deferred until all affected loads have completed.
Lock prefix instructions and reads from write-combined memory act as fences over which loads might not be reordered.
Streaming SIMD Extensions 4 (SSE4) introduced streaming loads with the VMOVNTDQA instruction (which corresponds to the _mm_stream_load_si128 intrinsic). Xbox One cores have no specific hardware acceleration for this instruction; however, the instruction does offer more opportunity for instruction reordering and this can sometimes produce a small speed increase.
An aligned 128-bit integer load can be done as an instruction operand:
vpaddd xmm0,xmm1,xmmword ptr [rax]
This is not the case with VMOVNTDQA, however:
vmovntdqa xmm0,xmmword ptr [rax]
vpaddd xmm0,xmm0,xmm1
Stores are queued in the Unified Store Queue (USQ). Unlike loads, stores are committed in strict age order. Up to two stores can be dispatched per cycle. Two halves of a misaligned store will be committed back to back, but these are not atomic: a minimum delay of one cycle will occur between storing each half.
The process of mapping a virtual address to a physical address is the same for a store as it is for a load.
When a store reaches the head of the queue, it will block if any of the following conditions apply:
To provide protection before the physical address has been obtained, a load will block if the least-significant 11 bits of its virtual address match a store’s least-significant 11 bits. In practice, we have seen that aliasing array access on 2-KiB alignment can halve the throughput of the memory operations.
Stores can potentially block for a considerable amount of time. The size of the USQ is 20 entries, and when the USQ is full, a store will stall the core. The USQ can accommodate only one misaligned store crossing a page boundary at a time. The core must block on store if attempting to add a second.
Store-to-load forwarding allows a load to pick up data that is still in the store queue and not yet committed to the data cache. This is a particularly important optimization in register-overspill scenarios. Many conditions must be satisfied for forwarding to work:
If the above conditions are not met, a load-hit-store stall is generated and the store must either complete or become non-speculative before the load is unblocked.
Register overspill of temporary variables on the stack is likely to satisfy all conditions except perhaps point 7, the speculative condition. Despite store-to-load forwarding, register overspill is still a situation to be avoided whenever possible. With register overspill, the CPU must issue the store instruction and then a load instruction, both of which have latency, as does the store-to-load forwarding mechanism.
In addition to the manual memory prefetch, Xbox One implements hardware memory prefetch. Leveraging the hardware prefetch can be useful as an optimization strategy in certain scenarios. Cache lines are 64 bytes.
Titles can issue an instruction to fetch a cache line through the _mm_prefetch() intrinsic. This instruction has a certain cost and might even be detrimental to performance, particularly if:
Care must be taken to ensure that the compiler does not reorder prefetch instructions and, in so doing, cause MAB saturation that stalls the core.
Xbox One has no mechanism for loading data into the L1DC but not the L2, because the L2 always includes the L1 caches.
The _mm_prefetch() intrinsic will accept the following hints:
Non-temporal prefetch is implemented by restricting the cache line to a single set of the L2. The L2 is a 2-MiB, 16-way set associate cache; that is, each set is 128 KiB. In effect, therefore, non-temporal prefetch constrains the cache line to a 128-KiB direct-mapped L2 cache. This setup is advantageous because direct-mapped caches are faster to access, and the other 15 sets in the L2 will be entirely unaffected, freeing up cache for other operations. The disadvantage is that each cache line can only be mapped to a single line in the 128-KiB set. The line used is determined by bits 7 to 17 of the address. Collisions result in evictions, which will cause considerable performance penalties if the cache line is needed again. Titles must take care to ensure that aliasing cache lines are not loaded before the previous line has been finished with. It can be particularly difficult to avoid collisions when multiple threads are concurrently issuing non-temporal prefetches.
It is possible to prefetch the address of functions into the L2 cache (which is a unified instruction/data cache) but not the L1 instruction cache. Doing this will pollute the L1DC with instructions, although the overall effect can be a performance increase.
It should be noted that prefetch does nothing when it is applied by write-combined memory; it simply wastes execution resources.
There are two hardware prefetch units per core, one for the L1 and one for the L2.
The L1 prefetcher monitors the MAB to determine patterns of missed cache lines. The prefetcher can spot both forward and backward array access. When an access occurs, it is compared against the currently active streams to see whether it fits one (or more) of them. If it does, the access is ignored by the prefetcher. Each time two memory accesses miss all existing active streams, a new stream is initialized. The prefetcher can maintain up to eight access streams at once and prefetch between one and four lines ahead. Each stream can only have one prefetch in flight at once.
The L2 prefetcher works in the same way. For each core, there is a prefetch unit that monitors the L1DC misses, ignoring instruction-cache misses. Each L2 prefetcher has 10 streams, each of which is associated with a single page.
Prefetching stops if either:
Because the hardware prefetch units cannot fetch from a different page, they will not trigger a TLB miss and will start priming the TLB ahead of time.
Manual prefetch can be used to kick off a new hardware prefetch stream.
The L2 cache has the following characteristics:
Each L1 can receive 16 bytes per cycle, and each bank can transmit 16 bytes per cycle. The maximum L2 to L1 throughput, therefore, requires that all banks upload to a different L1.
Lock prefix instructions (also known as atomic, load-op-store, or read-modify-write instructions) are issued through the Interlocked* API, std::atomic, CriticalSection, and so on. These pass down a special flag that locks the cache and bus on load and unlocks it after the store completes. Lock prefix instructions are added to the store queue like any other store operation. That is, the load will lock the cache and bus, but the pending stores must all be processed before the atomic store unlocks the cache and bus again. Reducing the chance that preceding stores will block will decrease the time it takes to unlock the cache and bus.
Lock prefix instructions cannot be executed speculatively and cannot be executed out of order. Executing a lock prefix load operation forces a series of actions to complete before the lock is granted:
These conditions ensure that the head of the store queue can be processed until the atomic store is encountered without having to make any changes to the cache or having to flush the write-gather buffers while the cache and bus are both locked.
There are several factors that make atomic instructions slow:
Locks—CriticalSection and mutex—are internally implemented with multiple atomic instructions and kernel calls. A single atomic instruction is therefore faster than a lock, but that still does not make it a fast operation. The atomic (indivisible) guarantee of a lock prefix instruction breaks when the data being acted on is not on its natural alignment. In such cases the load and store operations are broken into a high and low operation, and other concurrent accesses to that memory also made through lock prefix instructions can end up with incorrect data.
On Xbox One all memory addresses are virtual. The CPU and GPU’s virtual address spaces are kept in sync, meaning that it is safe to pass pointers between them.
Xbox One runs multiple concurrent operating systems. To provide security, each operating system has its own virtual address space, and physical RAM is managed by a hypervisor. This means that the process of mapping from virtual to physical memory is a nested one.
Translation look-aside buffers (TLBs) are caches of virtual-to-physical address translations at the level of a memory page. The data TLB (DTLB) consists of a small, fast Level 1 DTLB backed up by a slower, larger Level 2 DTLB. When the L2DTLB misses, requests are referred to the table walker (TWC). The TWC performs a search to find the missing table. Instruction fetch has its own ITLBs, which are not discussed here. Each core has its own L1DTLB, L2DTLB, and table walker. L2DTLBs are not shared between cores.
The L1DTLB is a 48-entry, fully associative, dual-ported cache that holds 40 4-KiB translations and eight 2-MiB translations. It has separate load and store request ports. The L1DTLB will return hit/miss status to LS the cycle after LSDC posts a request to the TLB. If the request was a miss, then it might result in a request to the L2DTLB. The depiction of the load process that was shown in Figure 1 is a slight simplification: the L2DTLB doesn’t directly make requests of the TWC. Rather, the load is replayed and the second time it fails the L1DTLB, a request is issued to the TWC instead of to the L2DTLB.
On Xbox One and Xbox One S, the L2DTLB is a 768-entry, fully associative, single-ported cache that holds 512 4-KiB translations and 256 2-MiB translations. Xbox One X increases the number of 4-KiB entries to 2,048.
The L2DTLB will report a hit or miss four cycles after the original LS request was posted. The L2DTLB is single ported, and only one request can be serviced at a time. This means that if both the load and store requests miss the L1DTLB, then it will arbitrate between these for the request to the L2DTLB. In general, load requests have priority.
The D1TLB uses a mixture of least-recently used (LRU) and round-robin (RR) retirement schemes, and the D2TLB uses RR schemes only. Because of this difference in retirement schemes, the D1TLB does not necessarily include the D2TLB.
Misaligned load/stores over 4-KiB page boundaries increase TLB pressure. It is possible that each half of the memory operation has a different memory type or even disjointed physical addresses.
If a title is only using 4-KiB or 2-MiB pages, then a percentage of the TLB caches is going to waste. 2MiB pages are well worth using.
The difference between hitting and missing the TLBs can be observed by comparing a random walk of cache lines against a pseudo-random walk. The pseudo-random walk hits the TLB far more often.
The L1DTLB has a single-cycle latency, while the L2DTLB has a four-cycle latency.
On Xbox One a virtual address consists of indices into several page tables and finally an offset into the physical page. Compared to 2-MiB pages, 4-KiB pages have one extra directory. The 48-bit virtual address space is divided as follows:
For 4-KiB pages:
a. Four 9-bit directories = 36 bits
b. A 12-bit page offset = 4 KiB.
For 2-MiB pages:
a. Three 9-bit directories = 27 bits.
b. 21-bit offset = 2 MiB.
Each core has a single hardware page table walker dictating the structure of the page tables. The table walker is shared and services both instruction and data TLBs.
Figure 2. Single-layer table walk on a 4-KiB page.
Note: Each directory is 9 bits, 512 entries large. In this illustration, it is shortened to two entries for the sake of brevity.
When performing a 4-KiB page-table walk, the hardware needs to issue five serial memory accesses to obtain the physical page. For 2-MiB pages, only four serial memory accesses are required. Each memory load is made through the L1DC and L2 cache, the same as regular memory requests. Speed therefore depends on the existing content of the caches and contention for the L2 and/or DRAM. Because TLB refill uses the data cache, 2-MiB pages also reduce cache pressure.
With the nested operating systems on Xbox One, what happens is that each page-table entry is another virtual address that needs a second page-table walk at the hypervisor level to find the actual physical address.
Note: The hypervisor uses 2-MiB pages.
Figure 3.
The 2-MiB TLB entries are shared between the hypervisor and the guest operating system, which is something to account for when deciding whether the L1TLBs might be churning.
In reality, many of the directory lookups will hit the cache. For example, unless the OS maps more than 512 GiB of virtual memory, the level 0 pointer will always be the same.
To test the performance characteristics of the Xbox One memory system, we constructed test code that timed a variety of combinations.
We ran multiple iterations of each combination, flushing out the data cache and TLBs between runs. This meant that the speeds for a low number of cache lines was very poor, and the work done was low for the cost of the initial table walks/TLB population.
We graphed the results of our testing, many of which are depicted in the appendices. Because testing every combination generated a huge amount of data, we chose not to reproduce the following:
Here is a short summary of the observations we made during testing.
Xbox One X has a higher memory bandwidth than Xbox One or Xbox One S. However, the CPU core works identically and at the same cycle counts. The relative differences between the memory operations are the same across the Xbox One family. The recommended optimization strategies for Xbox One also apply to Xbox One X. Because of this, we don’t provide separate numbers specific to Xbox One X. Follow these links to see graphic depictions of our different read-speed test results:
With five worker threads all accessing memory:
With five worker threads all accessing memory:
With five worker threads all accessing memory:
Pseudo-random tests involved visiting each cache line in a fixed-size dataset just once.
With five worker threads all accessing memory:
Xbox One X has a higher memory bandwidth than Xbox One or Xbox One S. However, the CPU core works identically and at the same cycle counts. The relative differences between the memory operations are the same across the Xbox One family. The recommended optimization strategies for Xbox One also apply to Xbox One X. Because of this, we don’t provide separate numbers specific to Xbox One X. Follow these links to see graphic depictions of our different write-speed test results:
With five worker threads all accessing memory:
With five worker threads all accessing memory:
With five worker threads all accessing memory:
With five worker threads all accessing memory:
We tested writing out the same amount of data using one pointer or four pointers to one-quarter of the same array. We also tested writing out only each alternate four bytes and skipping four bytes (so writing less); these are the restarting cases in the following graphs. We also tried using five streams but without any restarts.
With five worker threads all accessing memory:
Xbox One X has a higher memory bandwidth than Xbox One or Xbox One S. However, the CPU core works identically and at the same cycle counts. The relative differences between the memory operations are the same across the Xbox One family. The recommended optimization strategies for Xbox One also apply to Xbox One X. Because of this, we don’t provide separate numbers specific to Xbox One X.
We timed four streams writing out sequential data. After each four bytes written we inserted a fence instruction to flush all four write-gather buffers.
The resources listed below are recommended as prerequisites before reading this white paper. Both are available for download from Game Developer Network.