I've recently given a talk on queues and related optimisations, but due to the limitations of time, the Universe and Everything else had to cut short the deep dive into false sharing. Here however I'm not limited by such worldly concerns, so here is more detail for those who can stomach it.
What mean False Sharing?
Described before on this blog and in many other places (Intel, usenix, Mr. T), but assuming you are not already familiar with the issue:"false sharing is a performance-degrading usage pattern that can arise in systems with distributed, coherent caches at the size of the smallest resource block managed by the caching mechanism. When a system participant attempts to periodically access data that will never be altered by another party, but that data shares a cache block with data that is altered, the caching protocol may force the first participant to reload the whole unit despite a lack of logical necessity. The caching system is unaware of activity within this block and forces the first participant to bear the caching system overhead required by true shared access of a resource." - From WikipediaMakes sense, right?
I tried my hand previously at explaining the phenomena in terms of the underlying coherency protocol which boils down to the same issue. These explanations often leave people still confused and I spent some time trying to reduce the explanation so that the idea can get across in a presentation... I'm pretty sure it still left people confused.
Let's try again.
The simplest explanation I've come up with to date is:
- Memory is cached at a granularity of a cache line (assumed 64 bytes, which is typical, in the following examples but may be a smaller or larger power of 2, determined by the hardware you run on)
- Both reading and writing to a memory location from a particular CPU require the presence of a copy of the memory location in the reading/writing CPU cache. The required location is 'viewed' or cached at the granularity of a cache line.
- When a line is not in a threads cache we experience a cache miss as we go searching for a valid copy of it.
- Once a line is in our cache it may get evicted (when more memory is required) or invalidated (because a value in that line was changed by another CPU). Otherwise it just stays there.
- A write to a cache line invalidates ALL cached copies of that line for ALL CPUs except the writer.
Given the above setting of the stage we can say that false sharing occurs when Thread 1 invalidates (i.e writes to) a cache line required by Thread2 (for reading/writing) even though Thread2 and Thread1 are not accessing the same location.
The cost of false sharing is therefore observable as cache misses, and the cause of false sharing is the write to the cache line (coupled with the granularity of cache coherency). Ergo, the symptom of false sharing can be observed by looking at hardware events for your process or thread:
- If you are on linux you can use perf. (Checkout the awesome perf integration in JMH!)
- On linux/windows/mac Intel machines there is PCM.
- From Java you can use Overseer
- ... look for hardware counters on google.
Active False Sharing: Busy Counters
Lets start with a queue with 2 fields which share the same cache line where each field is being updated by a separate thread. In this example we have an SPSC queue with the following structure:Ignoring the actual correct implementation and edge cases to make this a working queue (you can read more about how that part works here), we can see that offer will be called by one thread, and poll by another as this is an SPSC queue for delivering messages between 2 threads. Each thread will update a distinct field, but as the fields are all jammed tight together we know False Sharing is bound to follow.
The solution at the time of writing seems to be the introduction of padding by means of class inheritance which is covered in detail here. This will add up to an unattractive class as follows:
Yipppeee! the counters are no longer false sharing! That little Pad in the middle will make them feel all fresh and secure.
Passive False Sharing: Hot Neighbours
But the journey is far from over (stay strong), because in False Sharing you don't have to write to a shared cache line to suffer. In the above example both the producer and the consumer are reading the reference to buffer which is sharing a cache line with naughty Mr. consumerIndex. Every time we update consumerIndex an angel loses her wings! As if the wings business wasn't enough, the write also invalidates the cache line for the producer which is trying to read the buffer so that it can write to the damn thing. The solution?
MORE PADDING!!
Surely now everyone will just get along?
MORE PADDING!!
Surely now everyone will just get along?
The Object Next Door
So now we have the consumer and the producer feverishly updating their respective indexes and we know the indexes are not interfering with each other or with buffer, but what about the objects allocated before/after our queue object? Imagine we allocate 2 instances of the queue above and they end up next to each other in memory, we'd be practically back to where we started with the consumerIndex on the tail end of one object false sharing with the producer index at the head end of the other. In fact, the conclusion we are approaching here is that rapidly mutated fields make very poor neighbours altogether, be it to other fields in the same class or to neighbouring classes. The solution?
EVEN MORE PADDING!!!
Seems a tad extreme don't it? But hell, at least now we're safe, right?
Buffers are people too
So we have our queue padded like a new born on it's first trip out the house in winter, but what about Miss buffer? Arrays in Java are objects and as such the buffer field merely point to another object and is not inlined into the queue (as it can be in C/C++ for instance). This means the buffer is also subject to potentially causing or suffering from false sharing. This is particularly true for the length field on the array which will get invalidated by writes into the first few elements of the queue, but equally true for any object allocated before or after the array. For circular array queues we know the producer will go around writing to all the elements and come back to the beginning, so the middle elements will be naturally padded. If the array is small enough and the message passing rate is high enough this can have the same effect as any hot field. Alternatively we might experience an uneven behaviour for the queue as the elements around the edges of the array suffer false sharing while the middle ones don't.
Since we cannot extend the array object and pad it to our needs we can over-allocate the buffer and never use the initial/last slots:
Note that this is costing us some computational overhead to compute the offset index instead of the natural one, but in practice we can implement queues such that the we achieve the same effect with no overhead at all.
Protection against the Elements
While on this topic (I do go on about it... hmmm) we can observe that the producer and the consumer threads can still experience false sharing on the cache line holding adjacent elements in the buffer array. This is something we can perhaps handle more effectively if we had arrays of structs in Java (see the value types JEP and structured arrays proposal), and if the queue was to be a queue of such structs. Reality being the terrible place that it is, this is not the case yet. If we could prove our application indeed suffered from this issue, could we solve it?Yes, but at a questionable price...
If we allocate extra elements for each reference we mean to store in the queue we can use empty elements to pad the used elements. This will reduce the density of each cache line and as a result the probability of false sharing as less and less elements are in a cache line. This comes a high cost however as we multiply the size of the buffer to make room for the empty slots and actively sabotage the memory throughput as we have less data per read cache line. This is an optimization I am reluctant to straight out recommend as a result, but what the hell? sometimes it might helps.
Playing with Dirty Cards
This last one is a nugget and a half. Usually when people say: "It's a JVM/compiler/OS issue" it turns out that it's a code issue written by those same people. But sometimes it is indeed the machine.
In certain cases you might not be the cause of False Sharing. In some cases you might be experiencing False Sharing induced by card marking. I won't spoil it for you, follow the link.
Summary
So there you have it, 6 different cases of False Sharing encountered in the process of optimizing/proofing the piddly SPSC queue. Each one had an impact, some more easily measured than others. The take away here is not "Pad every object and field to death" as that will be detrimental in most cases, much like making every field volatile. But this is an issue worth keeping in mind when writing shared data structures, and in particular when considering highly contended ones.
We've discussed 2 types of False Sharing:
What should you do?
We've discussed 2 types of False Sharing:
- Active: where both threads update distinct locations on the same cache line. This will severely impact both threads as they both experience cache misses and cause repeated invalidation of the cache line.
- Passive: where one thread writes to and another reads from distinct locations on the same cache line. This will have a major impact on the reading thread with many reads resulting in a cache miss. This will also have an impact on the writer as the cache line sharing overhead is experienced.
- Neighbouring hot/hot fields (Active)
- Neighbouring hot/cold fields (Passive)
- Neighbouring objects (potential for both Active and Passive)
- Objects neighbouring an array and the array elements (same as 3, but worth highlighting as a subtlety that arrays are separate objects)
- Array elements and array length field (Passive as the length is never mutated)
- Distinct elements sharing the same cache line (Passive in above example, but normally Active as consumer nulls out the reference on poll)
- -XX:+UseCondCardMark - see Dave Dice article
What should you do?
- Consider fields concurrently accessed by different threads, in particular frequently written to fields and their neighbours.
- Consider Active AND Passive false sharing.
- Consider (and be considerate to) your neighbours.
- All padding comes at the cost of using up valuable cache space for isolation. If over used (like in the elements padding) performance can suffer.
Belgium! Belgium! Belgium! Belgium! Belgium! Belgium! Belgium! Belgium! Belgium! Belgium! Belgium! |
What are the possible downsides of avoiding false sharing? Isn't the L1 cache an extremely valuable and size limited resource? The padding would cause the cache to fill up with padding that is never used.
ReplyDeleteDo you have any guidance to offer on how frequently this should be leveraged given the concern above?
You are absolutely right. From the linked Intel page:
Delete"Avoid false sharing but use these techniques sparingly. Overuse can hinder the effective use of the processor’s available cache. Even with multiprocessor shared-cache designs, avoiding false sharing is recommended."
Padding every little piece of data will dilute the usable content of your cache. You should reserve this technique for long standing shared data structures which are frequently accessed.
Note that for Java (compared with C) the problem is worse as cache line alignment of allocated objects and fields cannot be controlled, requiring us to use conservative padding and waste more.
Another issue for Java (compared with C) is the impossibility of inlining data structures, leading to many tiny object where notionally larger objects are 'intended'. In the case of the queue above where the buffer is allocated once, a struct like construction would have removed the need for the array edges padding and also the pointer chasing to get at the array data.
Nice post, though you have lost me in
ReplyDelete// Allocate extra 16 elements either side
{ E[] buffer = new ...[16 + + 16]; }
Why have u used 16 instead of 8, is it because some computer have 128 bytes architecture?
Right, I always wonder how closely people read the code ;-)
DeleteObject references are either 4 bytes(32bit JVMs/64bit JVM with -XX:+UseCompressedOops which is default) or 8 bytes.
It's safe to assume 4 bytes most of the time. 16*4 = 64.
You are right, I know that post and it is a wonderful collection of cache related phenomena :-)
ReplyDelete