Showing posts with label design_in_memory_cache. Show all posts
Showing posts with label design_in_memory_cache. Show all posts

Jun 30, 2026

[Design] Cache friendly design, CPU/Memory level

Reference:
What do you mean by "Cache Friendly"? - Björn Fahller
What Every Programmer Should Know About Memory

Simplistic model of cache behaviour

Includes

  • The cache is small
  • and consists of fixed size lines
  • and is very very fast when hit
  • and missing is very slow

Excludes

  • Multiple levels of caches
  • Associativity
  • Threading

All models are wrong, but some are useful


$ valgrind --tool=callgrind --cache-sim=yes --dump-instr=yes --branch-sim=yes
$ perf stat -e cycles,instructions,L1-dcache-loads,L1-dcache-Load-misses --call-graph=dwarf
$ perf record -e cycles,instructions,L1-dcache-loads,L1-dcache-Load-misses --call-graph=dwarf
$ pert report
$ hotspot perf.data

macos:
$ samply


Rule of thumb

follow/chasing pointer -> cache miss.

So,
Get rid of pointers.
Use std::vector (contiguous memory) vs. prev/next pointers.
std::vector be aware of memmove/memcpy.
memmove cause cache miss; if possible, memcpy.
Thus try to move less data.

binary tree/search still chasing pointer, 
can we get Log(n) lookup/insert without chasing pointers?
Heap. Use contiguous memory(e.g. std::vector) to implement.

But the heap is not searchable. Need extra contiguous memory to store the elements in order;
and the element holds the index to the contigous memory. Thus when the smallest heap
is processed, use the index to the contiguous memory and process that chunk.
Trade off: extra memory usage for speed and less cache miss.

Can we make it better?
B-Heap(binary heap implemented to keep subtrees in a single page); smaller heap fit into cache-line.
(not really better, depends on use case. i.e. if within a loop, the cache is hot, good.
otherwise, not worth the extra instructions due to cache is flushed anyways.)










Linear search is good iff the data set is small. (cpu prefetch within the cache size)
If data set is large, big O still dominates.




Rules of thumb

  • Folling a pointer is a cache miss, unless have information to the contrary.
  • Smaller working data set is better.
  • Use as much of a cache entry as we can.
  • Sequential memory accesses can be very fast due to prefetching.
  • Fewer eviceted cache lines means more data in hot cache for the rest of the program.
  • Mispredicted branches can evict cache entries (spectre/meltdown)
  • Linear access in contigous memory rules for small data sets.
  • Measure measure measure. (intuition can be wrong.)

Reference:

Important things to know:

  • The hardware prefetchers only work for streams within 4KiB pages.
    (There is a page-crossing L1 prefetcher in Ivy bridge, but it does not seem to make a lot of difference in my tests.)
  • The L2 hardware prefetchers can only track a limited number of pages (16 to 32, depending on the processor model).
  • Hardware prefetchers tend to be much more aggressive at fetching read streams than at fetching write streams.
  • Transpose operations have to deal with non-contiguous accesses over some large area -- it helps if that area is small enough to be mapped by the TLBs.
    For Ivy Bridge, this is 4KiB * 512 entries = 2 MiB when using the default page size.
  • Strided accesses in transpositions can very easily cause pathological cache conflicts.
  • If the stride is a multiple of 4KiB, the L1 Data Cache will only hold 8 entries before beginning to overflow.
  • Memory bandwidth performance in Intel processors is strongly degraded if a store maps to the same congruence class in the L1 cache as a preceding load.
    This will happen whenever a store maps to the same location in its 4KiB page as a recently preceding load.
From these principles, it is pretty easy to come up with tuning guidelines that work well for transpositions.
  1. Pad arrays to avoid power-of-two strides (or offsets) whenever possible.
    Unrolling loops by 8 is usually a very good strategy.
  2. This minimizes cache conflicts, so you (typically) get to read all the elements in a cache line before it gets evicted.
  3. The number is small enough that the compiler does not typically get terribly confused about register scheduling.
  4. When working in higher dimensions, make sure that the sub-blocks resulting from the unrolling don't cover more space than the TLB can cover.
  5. This may mean unrolling by less than 8 for some or all of the loops.
  6. Analysis from first principles is challenging for 2D transpositions, and is probably not practical for higher than 3D.
  7. Favor contiguous reads over contiguous stores, but remember that there is minimal benefit to contiguous reads longer than 4 KiB.
  8. If using streaming stores, remember to construct the code to store 64 contiguous bytes all at once, then store to the next block.
    This will maximize the number of full write-combining buffers that get used and will minimize the read/modify/write cycles at the DRAM (which are required if a partially full write-combining buffer gets flushed).
Unrolling and blocking are required to get good performance -- especially with arrays that have large power-of-two dimensions.



Hardware CPU Prefetching, Cache Friendliness, and Big-O Complexity

1. How CPU Prefetching Works on std::vector

Hardware CPU prefetchers actually work better on large std::vectors than small ones, and function seamlessly regardless of how large the vector grows (up to the physical limits of RAM).

In fact, CPU hardware prefetchers rely on streaming through large blocks of memory to function effectively.

Mechanics of Hardware Prefetching

std::vector stores its elements in a single, contiguous block of dynamically allocated memory. When iterating through a vector linearly, memory accesses occur at sequential byte addresses ($A, A+64, A+128, \dots$).

  1. Pattern Detection: Modern CPU cores feature dedicated Stream Prefetchers and Spatial Prefetchers (L1/L2 data prefetchers). When the CPU detects sequential cache misses (typically 2 to 4 consecutive cache line accesses in the same direction), it identifies a linear stream.
  2. Ahead-of-Time Fetching: Once a stream is detected, the prefetcher speculatively issues memory reads for upcoming cache lines ($N+2, N+3, \dots$) into L2 or L1 cache before execution requests them.
  3. Pipelining Latency: As long as scanning continues sequentially, memory fetch latency is completely hidden behind instruction execution.

Impact of Dataset Size

  • Small Vectors (< 1 KB / ~16 Cache Lines): The vector fits entirely within the L1 or L2 CPU cache after the first access anyway. Furthermore, by the time the hardware prefetcher detects the sequential pattern (after 2–3 misses), the iteration may already be near the end of the vector.
  • Large / "Unlimited" Vectors (Gigabytes): The vector exceeds CPU cache capacity, meaning every element must be fetched from main RAM. Because the access pattern is perfectly predictable and long-running, the hardware prefetcher stays active at full throughput, streaming cache lines continuously from RAM with minimal cache-miss stalls.

Caveats: When Prefetching Breaks Down

While the size of std::vector does not limit the hardware prefetcher, the access pattern and data layout do:

  • Non-Sequential Access: Accessing elements randomly (vec[rand()]) or via pointer chasing (e.g., storing raw pointers inside std::vector<T*>) prevents the prefetcher from predicting the next address.
  • Page Boundaries (TLB Misses): Every 4 KB (or 2 MB) OS memory page boundary forces the prefetcher to pause until the CPU translates the next virtual page address via the Translation Lookaside Buffer (TLB).
  • Page Faults / Virtual Memory: If a massive vector spills out of RAM into swap disk space, page faults will stall the CPU regardless of prefetching.
  • Cache Pollution & Bandwidth Saturation: Iterating over a multi-gigabyte vector with minimal compute per element turns the execution into a memory-bandwidth-bound bottleneck.

2. Linear Search vs. Binary Search: Hardware Cache vs. Big-O Complexity

The assertion that "Linear search is good iff the data set is small (cpu prefetch within the cache size). If data set is large, big O still dominates" highlights a fundamental crossover point in systems programming: hardware cache efficiency vs. asymptotic algorithmic complexity ($Big-O$).

Why Linear Search Wins for Small Data Sets ($N$ is Small)

Actual execution time is modeled by:

$$T(N) = C \cdot f(N)$$

where $C$ is the constant factor determined by memory layout and hardware overhead, and $f(N)$ is the algorithmic growth function.

For small data sets (e.g., $N < 64$ elements):

$$C{\text{linear}} \cdot N < C{\text{binary}} \cdot \log_2 N$$

Structural Reasons for a Smaller $C_{\text{linear}}$:

  1. Hardware Prefetching & Spatial Locality: Linear search scans contiguous memory (std::vector/array). As soon as element 0 is accessed, the CPU cache line (64 bytes) automatically pulls in adjacent elements. The CPU stream prefetcher speculatively fetches subsequent cache lines directly into L1/L2 cache before execution reaches them.
  2. Zero Pointer Chasing: Operating on contiguous memory incurs L1 cache latency ($\sim 1\text{ ns}$) rather than random memory node jumps ($\sim 50\text{--}100\text{ ns}$ DRAM latency).
  3. Branch Predictability: A simple sequential loop is trivial for the CPU's branch predictor to handle, minimizing pipeline stalls and flushes.

At small $N$, the data set fits entirely within L1/L2 cache, making memory access essentially instantaneous. The structural overhead of binary search (midpoint calculation, branches, non-sequential jumps) outweighs the cost of scanning a few adjacent cache lines sequentially.


Why Big-O Dominates as Data Grows ($N$ is Large)

As $N$ grows large (e.g., $N = 1,000,000$), hardware prefetching cannot overcome mathematical growth rate differences.

Metric Linear Search $O(N)$ Binary Search $O(\log_2 N)$
Operations for $N = 1,000,000$ $\sim 500,000$ comparisons (avg) $\sim 20$ comparisons
Memory Access Pattern Sequential / Prefetched Non-sequential / Cache line jumps
Primary Bottleneck Memory Bandwidth & Total Operations Cache Latency

Why Hardware Prefetching Fails to Save Linear Search at Scale:

  1. Arithmetic Overwhelms Prefetching: Even if hardware prefetching streams data from main RAM at maximum bus bandwidth ($\sim 50\text{--}100\text{ GB/s}$), scanning $10^6$ elements still requires visiting half a million items on average.
  2. Memory Bandwidth Saturation: Once the data set exceeds cache capacity (L3/RAM), linear search becomes entirely memory-bandwidth bound. Prefetching keeps the CPU pipeline fed, but the bus can only transport data so fast.
  3. Logarithmic Superiority: In Binary Search, even if every single step of the 20 comparisons incurs a full DRAM cache miss ($\sim 70\text{ ns}$ each):

$$20 \text{ misses} \times 70\text{ ns} = 1.4 \;\mu\text{s}$$

Scanning megabytes of data sequentially via Linear Search takes hundreds of microseconds, making Binary Search orders of magnitude faster despite poor prefetch alignment.


Summary: The Crossover Point

Time (ns)
   ^
   |       /  Linear Search O(N) [Small C, bad growth]
   |      /
   |     /    <--- Crossover Point (N ≈ 32 - 128 elements)
   |    /___________ Binary Search O(log N) [Large C, excellent growth]
   |   /
   +-------------------------------------> N (Data Size)
  • Small $N$ ($N \lesssim 64$): Constant factor $C$ dominates. The cache-friendliness, vectorization potential, and prefetching of linear search make $O(N)$ faster than $O(\log N)$ or $O(1)$.
  • Large $N$ ($N \gg 1000$): Algorithmic growth $f(N)$ dominates. The exponential reduction in operations from $O(\log N)$ or $O(1)$ easily beats reading gigabytes of prefetched memory linearly.

Jul 7, 2020

[virtual memory] recap

Reference:
Set-associative cache


Recap

Program address in linux/elf



Virtual Memory is used due to:
  • RAM is not sufficient
  • Holes in address space
  • Programs writing over each other


Programs can access any byte in their 64-bit address space


Page Out
  • While not enough memory, paging out(base on different algorithm) for free physical memory and flush data into next level storage system(usually disk)

    
Page Tables
  • Page Table Entry(PTE) (usually 4kb page size (32bit) or 2mb (64bit))


Page faults (slow)
  • Pages not in RAM
  • CPU generates a page fault exception caught by OS
  • OS flush out data from memory to give room for data on disk



Virtual Address <-> Page Table <-> Physical Address(PA)

Virtual Address layout: (2^32 or 2^64 physical memory wide)

20bit virtual page number (virtual page number, map to physical page number) + 12bit page offset (does not get translated into PA, the size 
depends on page size, e.g 4KB we need 2^12, 12 bits offset, i.e 2^12 = 4096 bytes = 4KB, remember, in memory, the basic unit is byte)

Physical Address layout: (2^N where N is the size of physical memory are installed on this box)

16bit physical page number + 12bit page offset (same as VA's page offset)


Page Table Size

Page table remains in the memory all the time, for lookup.


Page Table Lookup




Page Table Indirect



How to make virtual memory FAST?

List operations:

  • Access the page table in RAM
  • Translate the address
  • Access the data in RAM


Cache for page table: Translation Lookaside Buffer(TLB)

    Virtual Address <-> TLB(small and fast) <-> PA



TLB should be small

  • iTLB (for instructions)
  • dTLB (for data)
  • Second TLB
  • 64 entries, 4 way (4kb pages)
  • 32 entries, 4 way (2mb pages)
i.e If program runs in 4kb/2mb pages of data, there's no update needed for TLB.

Good: Page in RAM

  • PTE (page table entry) in the TLB
  • PTE not in the TLB

Bad: Page not in RAM

  • PTE in the TLB(unlikely)
  • PTE not in the TLB


TLB layout

tag + physical page number
  • tag: physical memory index (virtual address(VA)'s virtual page number)
  • physical page number: physical address

TLBs and CPU caches(L1/L2/L3)

  • Physical Cache (slow, must do TLB lookup before accessing the cache)
  • Virtual Cache (fast, TLB lookups only when having a cache miss)



VIPT (Virtually Indexed, Physically Tagged)

  • Data in the cache in indexed by the virtual address
  • But tagged by the physical address
  • Only get a hit if the tag matches the physical address, but can start looking with the virtual address
    i.e TLB translation and Cache lookup at the same time(in parallel).


Reference:
David Black-Schaffer - virtual memory

Jul 4, 2020

[Pacific++ 2018][re-read] "Designing for Efficient Cache Usage" - Scott McMillan

Reference:
Latency Numbers Every Programmer Should Know:
https://colin-scott.github.io/personal_website/research/interactive_latency.html



Cache Lines



 

Hardware Prefetch

  • Predictable access patterns are faster
  • We want sequential locality

 

Access Locality

  • Cache locality
    • Spatial
    • Temporal
  • Beware the algorithm/data structure to use honors cache locality.
  • Look beyond just big-O notation as constant-time costs can differ significantly.
  • Large benefit in hitting faster cache levels.
  • In C++, allocators matters. In Go(lang), the standard implement dealt with this problem.
  • In Go(lang), use flat map instead of std map.

 
 

Multipe CPU Core Considerations



 
 

Write Combined Memory

  • Accumulate writes to flush as 64Bytes  operations
  • Partial buffer flush causes: (avoid this)
    • Not writing all bytes convered by a buffer
    • Writing too many streams at once
    • Atomic read-modify-write operations
  • Write Combined memory read causes:
    • C++ bit fields
    • Optimization
    • Virtually always an accident (read the source code of implement)
    • Solution: Expose write-only interface
  • Non-temporal writes on x86: 
    Optimizing Cache Usage With Nontemporal Accesses
    • Use compiler intrinsics:
      • SSE2
        • _mm_stream_si32: store 4 bytes
        • _mm_Stream_si128: store 16 bytes
      • AVX
        • _mm256_stream_si256: store 32 bytes
      • AVX-512
        • _mm512_stream_si512: store 64 bytes



Address Translation

  • Platform-specific
  • Not directly pageable
  • Difficult/slow to allocate
  • Linux: Use hugepage

Jun 21, 2020

[Paper read] BP-Wrapper: A System Framework Making Any Replacement Algorithms (Almost) Lock Contention Free

[Paper] BP-Wrapper: A System Framework Making Any Replacement Algorithms (Almost) Lock Contention Free
http://web.cse.ohio-state.edu/hpcs/WWW/HTML/publications/papers/TR-09-1.pdf


Issues

In a  multi-core processors environment, a new challenge for buffer management to address is to retain its scalability in responding to the highly concurrent data processing demands.
  1. The page replacement algorithm, a major component in the buffer management, can seriously degrade the system’s performance if the algorithm is not implemented in a scalable way. 
  2. A lock-protected data structure is used in most replacement algorithms, where high contention is caused by concurrent accesses.

A common practice is to modify a replacement algorithm to reduce the contention, such as to approximate the LRU replacement with the clock algorithm.

However, this hurts the hit-ratio.

In this paper, authors introduced batching and prefetching techniques to reduce lock contention and to retain high hit ratio.


Introduction

  • How to get replacement algorithm makes replacement decisions both in an effective and scalable way.
  • Disk I/O is expensive.
  • Virtual memory, and I/O buffers, focusing on the improvement of hit ratios by organizing and managing deep page access history.
  • Representative algorithms include those that address the weaknesses of LRU, such as LIRS , 2Q , and ARC.
The above algorithms needs a lock to hold the latch for updating the cache.
Performance degradation due to lock contention can be significant in large-scale systems. 
This subject has been a major research issue for years. 
Paper's experiments show that contention on the lock associated with replacement algorithms may reduce database throughput by nearly two folds in a 16-processor system.

Two factors contributing to the performance degradation.
  • One factor is the frequency of lock requests. Currently, the lock is globally shared by all transaction-processing threads to coordinate their page accesses. It is requested every time a thread accesses a page.
  • Another factor is the cost to acquire a lock, including changing lock state, busy-waiting, and/or context switches.
In general, lock contention can be reduced by using distributed locks to reduce lock granularity .
However, as the recorded history information is localized to each partition, the lack of global history information can be harmful to the performance of the replacement algorithms. 


Solution

  1. batch execution, which amortizes lock contention overhead among a batch of page accesses.
    Using private ring-buffer for each thread vs. shared ring-buffer for all threads, choosing former reduce complexity and performance.
    Also, private ring-buffer preserves the sequence for each thread.

  2. prefetching, which reduces the average lock-holding time by pre-loading necessary data for the replacement algorithm into the processor cache.
    As for prefetching, is closely related to CPU cache. Before lock required by a Core into its cache,the thread reads the about to be modified data into CPU's cache, i.e in LRU, the pointers of head node's forward/backward pointers.
    After a WMB(write-memory barrier) which triggered by a lock, data is written into store-buffer and invalid-queue request has sent.
    For prefetched data(pointers in LRC), the prefetch is a sheer read into CPU catch without modification.
    Once the CPU has the lock, re-read the prefetched data, if the prefecthed data is invalidated, CPU will re-read data from other Cores; however, if not, the data is used.

Jun 20, 2020

[Go][design] high through-put low contention in memory cache

Project https://github.com/dgraph-io/ristretto as example


Fast Access:

batching:

  • use ring-buffer for each lock and update the global cache.

generate a fast hash:


High Concurrency and Contention Resistance:

Get (tinyLFU):

  • use sync.Pool to implement striped, lossy ring-buffers that have great performance with little loss of data to update frequency. 
  • Use with channel for async update.

Set (tinyLFU + Count–min sketch): 

  • use a channel to capture the Sets, dropping them on the floor if the channel is full to avoid contention.    

Memory Bounding:

  • An infinitely large cache is practically impossible.
  • A cache must be bounded in size. Many cache libraries would consider cache size to be the number of elements, this is naive and only works in a workload where values are of identical size. Most workloads, however, have variable-sized values. One value could cost a few bytes, another a few kilobytes and yet another, a few megabytes.Treating them as having the same memory cost isn't realistic.

Idea:

  • Attach a cost to every key-value.
  • Users can specify what that cost is when calling Set. 
  • When the cache is operating at capacity, a heavy item could displace many lightweight items.

Admission Policy:

  • What should we let into the cache when the capacity is hit?
    The goal, obviously, is to let in new items if they are more “valuable” than the current items. (there is overhead)
  • Using LFU, not LRU:
    Paper: TinyLFU: A Highly Efficient Cache Admission Policy
    • The main idea is to only let in a new item if its estimate is higher than that of the item being evicted. 
    • Use count-min sketch.
    • After N key increments, the counters get halved.
    • Use TTL. A key that has not been seen for a while would have its counter get reset to zero.
  • To find a key with low ɛ, use randomness sampling, which provided by iterating through Go's map key.
  • If the incoming key's ɛ is higher than ɛ from randomness sampling, reject the incoming key, otherwise, incoming key is stored.
  • Note that the cost of the incoming key does not play a factor in choosing the eviction keys.

DoorKeeper:

  • When capacity hits, use a bloom filter to first check if the key has been seen before. 
  • Only if the key is already present in the bloom filter, is it inserted into the TinyLFU.

Metrics:

  • When using atomic counters for metrics, be ware of false sharing between caches among CPU cores.
  • While cache line size, on my thinkpad is 64-bytes, use atomic.AddUint64 as atomic counter.



Reference:

Design Of A Modern Cache (Benjamin Manes)
http://highscalability.com/blog/2016/1/25/design-of-a-modern-cache.html

BP-Wrapper: A System Framework Making Any Replacement Algorithms (Almost) Lock Contention Free
https://dgraph.io/blog/refs/bp_wrapper.pdf

Adaptive Software Cache Management
https://dgraph.io/blog/refs/Adaptive%20Software%20Cache%20Management.pdf

TinyLFU: A Highly Efficient Cache Admission Policy
https://dgraph.io/blog/refs/TinyLFU%20-%20A%20Highly%20Efficient%20Cache%20Admission%20Policy.pdf

The State of Caching in Go
https://dgraph.io/blog/post/caching-in-go/

Count–min sketch
https://en.wikipedia.org/wiki/Count%E2%80%93min_sketch