Optimizing a ring buffer for throughput (2021)
rigtorp.se
rigtorp.se
See this page (about half way down) https://ruby0x1.github.io/machinery_blog_archive/post/virtua...
* https://web.archive.org/web/20140625200016/http://libh.slash...
VRB:- These functions implement a basic virtual ring buffer system which allows the caller to put data in, and take data out, of a ring buffer, and always access the data directly in the buffer contiguously, without the caller or the functions doing any data copying to accomplish it.
* The "Hero" code
http://libh.slashusr.org/source/vrb/src/lib/h/vrb_init.c
* Front & back end source directories:
https://web.archive.org/web/20100706121709/http://libh.slash...
//-- Limit request to available data.
if ( arg_size > vrb_data_len( arg_vrb ) ) {
arg_size = vrb_data_len( arg_vrb );
}
//-- If nothing to get, then just return now.
if ( arg_size == 0 ) return 0;
//-- Copy data to caller space.
memcpy( arg_data, arg_vrb->first_ptr, arg_size );
If at the time of the check for the amount of available data there is less data available than requested but additional data gets added to the buffer before arg_size gets updated, then this might get more data than requested and overflow the target buffer. At least vrb_read() and vrb_write() have the same bug.Concurrency issues existed in the days of yore .. but they arose in different ways with different timings.
It's an odd bit of code archeology recalling a concept ( mmap ring buffers ) from decades past and then hunting to find the best remaining example - much of LibH was 'clean' rewrites of code from the authors past work.
Otherwise, yes, that's what I mean by the OS bending over backward.
Linus has choice words for such architectures.
All modern caches PIPT, VIPT, or even VIVT must work with this scheme as it's semantically transparent to virtual memory. The performance of line-crossing accesses is a totally different issue and I would never used this "trick".
This clever ring buffer has hidden costs that don't show up in microbenchmarks testing only the push() and pop() operations on an initialized ring buffer. These can be very high and impact other software as well.
The initialisation requires multiple system calls to setup the aliased memory mapping and it has to be a multiple of the page size. This can be very expensive on big systems e.g. shooting down the TLBs on all other CPUs with interprocessor interrupts to invalidate any potentially conflicting mappings. On other end small systems often using variations of virtual indexed data caches and may be forced to set the aliased ring buffer pages as uncacheable unless you can get the cache coloring correct. And even for very small ring buffers you also use at least two TLB entries. A "dumber" ring buffer implementation can share part of a large page with other data massively reducing the TLB pressure.
Abusing the MMU to implement an otherwise missing ring addressing mode is tempting and can be worth it for some use-cases on several common platforms, but be careful and measure the impact on the whole system.
Apparently Intel uses a variant called MESIF and AMD went for MOESI (https://www.realworldtech.com/common-system-interface/5/, https://en.wikipedia.org/wiki/MESIF_protocol) (https://www.amd.com/system/files/TechDocs/24593.pdf, https://en.wikipedia.org/wiki/MOESI_protocol)
I used this multiproducer multiconsumer ringbuffer
https://www.linuxjournal.com/content/lock-free-multi-produce...
I am inspired by LMAX Disruptor. I like the idea of splitting all IO into two halves: submit and reply. At one point I was looking slides of some company who used LMAX Disruptor pattern for their IO and had high performance.
My extremely naive Java benchmark to test locks I get 35,871,639 locks per second on 12 threads with contention. I am trying to work out how to improve the performance of the multiproducer multiconsumer ringbuffer which gets 10 million requests per second it should be higher.
https://github.com/samsquire/multiversion-concurrency-contro...
I want to create a message queue that uses ringbuffers to communicate between threads efficiently and elegantly and handles the splitting of IO in half. I want to combine coroutines and threads together.
I've found a lot of problem spaces it fits into perfectly. For example, any sort of MPSC arrangement. Literally any. The most obvious being if you want to DIY your own database.
If performance is of interest to you, you may want to check out the latest variants of Disruptor on .NET. Figures nearing 500 million per second range are present here, and will likely be exceeded in future versions of .NET:
https://medium.com/@ocoanet/improving-net-disruptor-performa...
The thing .NET has to its advantage (not sure if this is true anymore) is how structs vs classes work in memory. This is why there is a speedup with ValueDisruptor<T>.
> how to improve the performance of the multiproducer multiconsumer
> multiconsumer
Fundamentally, I think multi-consumer is going to chop a whole order of magnitude off no matter how you slice it. Having just one consumer (aka a 'writer') is a foundational aspect of the performance games played in this arena.
They've gone with a block-based approach for SPSC/MPSC/MPMC that blows the linux implementation, DPDK and the LMAX Disruptor out of the water.
500 million requests per second. I think that's awesome for _intra thread communication_. I have a sharded bank simulation which naively sends money between accounts and it gets 700 million transactions a second which is the aggregate of 12 threads all transacting local to a thread, so 500 million across threads seems good (I split money up rather than accounts per thread). I see that some web frameworks can handle up to 600,000 requests per second on techempower benchmarks. So we need to combine the best of each!
Especially the part about one writer. I have a journal entry called "Golden concurrency" inspired by someone's comment on HN (I really should have linked it!) talking about writing to a database from 3 processes and they decided to split the database into 3 databases rather than suffer contention from concurrency control.
"Golden concurrency" is my term or desire that degradation caused by multiple producers/multiple consumers in different configurations could be optimised for and could be a scenario that is handled without performance degradation. I suspect you need a different design if you have 1 producer:1 consumer producers to consumers, or 1 producer:N consumers or N producers:1 consumer or N producers:N consumers.
I enjoyed the following benchmarks with Kafka and RedPanda which made me think of this too.
https://jack-vanlightly.com/blog/2023/5/15/kafka-vs-redpanda...
For an example, look at how multimedia frameworks are designed: Media Foundation, gstreamer, ffmpeg, to lesser extent V4L2 and ASIO. For audio, they often handle samples coming at 48kHz, and they are usually heavily multithreaded, yet they don’t have ring buffers for individual samples, their ring buffers are keeping batches of them.
Don't you still need an unused queue item even with a power of two size? Isn't the point of having a power of two size that you can calculate the next item index in the buffer using binary rollover to go back to 0 as opposed to requiring a comparison and a branch.
Overflow in 32-bit indices can be surprisingly frequent in fast computers.
You can also still use 32bit indices and let them wrap around naturally and make it work as long as your queue size is less than 2*31. Comparison just becomes a little bit trickier. That's how TCP works for example.
Why wouldn't this work for, e.g., N=13?
So for instance if you have a buffer with 8 entries you have a read pointer and a write pointer, both wrapping at 16 (4bits). When you address the buffer you ignore the MSB, when you want to test for empty you do `read == write`, when you want to test for full you do `read XOR write == 0b1000`.
So you only have extremely cheap comparisons, bitwise XOR and counter increments to implement all operations.
The library is written in standard C++11 (but additional API's for higher C++ versions have been added), uses no dynamic allocation and is configurable so it is both big metal and deeply embedded friendly.
https://stackoverflow.com/questions/22766191/what-is-false-s...
https://en.cppreference.com/w/cpp/thread/hardware_destructiv...
Do you mean something like this?
struct Inner {
int\* data;
size_t size;
size_t cachedIdx;
};
struct ringbuffer3 {
// No vector<int> [...] here, saves a cache line
alignas(64) std::atomic<size_t> readIdx_{0};
alignas(64) Inner writeInner;
alignas(64) std::atomic<size_t> writeIdx_{0};
alignas(64) Inner readInner;
// Constructor ...
};
Am I understanding correctly?(edit: formatting)
next_write_idx = write_idx + 1;
if (next_write_idx == read_idx) return full;
ring[write_idx & (RING_SIZE - 1)] = data;
write_idx = next_write_idx;
This eliminates one possible incorrectly predicted branch and allows you to use all slots of the ring. The original code always leaves one slot empty to avoid the ambiguity between empty and completely full (where in both cases, the indexes have the same value). This could matter for small rings.The way to think of the indexes: they hold the number of items written to or read from the ring since reset.
I guess a similar optimization to the cached entries is to pop as many items as possible before returning:
local_write_idx = write_idx;
local_read_idx = read_idx;
while (local_read_idx != local_write_idx)
process(ring[local_read_idx++] & (RING_SIZE - 1)]);
read_idx = local_read_idx;
return;
This has other advantages: the ring read is a tight loop and you are repeatedly calling "process"- the working set (the cache and branch predictors) will be all set up for it after the first call.I am vaguely aware of some of the topics discussed in here like how when he mentions setting the write and read indices to the cache size he is referencing the L1 cache on the processor and how the L1 and L2 caches are different and every step up in memory on the processor is a order of magnitude difference in time to access. I know this about as well as the fact that I know that the stratosphere and troposphere are separate from the atmosphere, in that I know they exist but I don't understand all the implications.
I'd like to learn more of this(relative to my own software layer) deep magic but I don't know where to start.
[0] https://youtube.com/playlist?list=PLAwxTw4SYaPmqpjgrmf4-DGla... [1] https://youtube.com/playlist?list=PL5PHm2jkkXmi5CxxI7b3JCL1T...
Well, there's significantly more if you're trying to implement coherence as an architect, but for the software author that's pretty much it.
Cache lines have a size, if you have a line that's shared between two cores they keep needing perform an eviction each time one of the cores takes exclusive control to perform a write to that cache line, this is slow, the end.
If you want the definitive source for this stuff, the Intel Architecture SDM, Volume 3 Ch12 would be a place to start. Then the Intel Optimization manual, Ch 3.6/3.7, and the Ch 2.x.y chapters for the micro-architecture which will describe the cache layout
The back pressure is handled with a credit scheme and the producer gets an updated copy of most recently-know consumer read counter with every completion message back.
Using this scheme you can achieve the optimal performance with just a single cache line of traffic for each cache-line sized message.
Unfortunately I haven't found a SPSC Rust crate that does this, but https://github.com/polyfractal/bounded-spsc-queue [abandoned] comes close.