Posts in Performance (20 found)

Workshop Basel day three

See also: day one, day two . There is only one thing that is better than two days of HTTP workshop, and that is of course three days of HTTP workshop. The final day of this edition of the series started out with us again shuffling around where we parked ourselves around the big table. Except Mr captain of course who once again got to herd us forward through another day from the same seat. MOQ ( Media over QUIC transport ) is not HTTP, but it uses QUIC so it is at least tangentially interesting and it involves a lot of the same people so this status update still felt welcome and suitable. Compared to existing HTTP based solutions, MOQ is supposed to offer less complexity and lower latency. The moon landing was broadcasted with less latency than current live-streamed TV and maybe MOQ can make us come close to those numbers again. In MOQ clients subscribe to a track that then contains a lot of objects that are delivered. It’s not the request + response approach of HTTP. The fact that this is not HTTP of course brings a lot of questions and well, doubts, and we lingered on various aspects of this topic for quite a while. My prize for the best slides of the HTTP workshop 2026 goes to [redacted] for the excellent use of potato images in their presentation. PTTH is HTTP spelled backwards, commonly pronounced as PoTaToH. A client sets up the connection but the actual HTTP request is sent from the server to the client. One of the intended use cases for this, is to allow an origin server to connect to the CDN proxy and then be able to deliver traffic to the world, rather than to have the CDN connect to the origin the way they usually do. Apparently most CDNs already have custom and proprietary solutions for exactly this kind of feature, so maybe doing it in a standard way instead makes sense? The draft explains the new proposed way to continue a previously interrupted upload over HTTP. The upload request gets a Location: header back for the resource being uploaded, and if it gets stopped prematurely, a client can then HEAD that resource, figure out the size and then do a second upload (using the PATCH method) request that tells the server that this transfer should start at offset X. Exactly how this should be supported in browser’ upload forms seemed a little bit uncertain . For my own sake I can see a challenge to implement this nicely for curl in particular when the upload is using formpost upload (curl’s -F flag) which after all still is a very common way to do uploads on the current web. I’ll return to this topic at a later time when I written an implementation to test… io_uring is a Linux asynchronous I/O framework that avoids the overhead of traditional system calls. It uses two shared ring buffers between user space and the kernel, allowing applications to batch I/O operations with zero-copy efficiency. The feature is disabled by Google in ChromeOS, Android and in production Google servers which certainly holds back some use of it. io_uring can be helpful to speed up things, but might be complicated to use in existing software architectures and the presentation went into some details on why this is so. A walk-through of some of the recent developments and improvements in Firefox’s UDP networking stack . Going from single datagrams to the modern ways to ship large chunks of data offloaded to the kernel to speed things up. Upload throughput in Firefox is up 60-90% over the last 11 releases. Lots of fun graphs and metrics were shown. This work is based on the quinn-udp stack. Happy Eyeballs v3 is coming and Firefox is implementing it . It now takes into account many more data sources than before, including alt-svc and HTTPS-RR and races connections against each other to use the one that connects first. There are some recommended timers in the specification and parts of the discussion was around how maybe the timers could instead be tightened a bit, and maybe the delay between the subsequent attempts could then use an exponential backoff instead sticking to a fixed interval? (I know I’ll discuss some of these details with my curl hacker friends and see what we should adjust… curl already supports most of the Happy Eyeballs v3 specification.) As we approached the end of the day a few shorter topics were ventilated to give us a little more to consider before going home: With this, the seventh HTTP workshop had ended. Again a very fine event. This time graciously sponsored and arranged by Adobe. Thank you everyone! The general idea is to continue with these events roughly every second year and I support this. The HTTP workshops are definitely one of my favorite events. The top image on this post was used in the final presentation and the author told me he is aware of the AI errors in there, “of which there are at least two”. Why is there no UTF8 in URIs? “If we would do it again, we would have allowed UTF8 in there” was said by someone who was there in the mid 1990s… Optimistic DNS is a draft. Use stale DNS cache data while getting the new. Connection remains alive for 120 seconds while DNS data is often not cached for even 30 seconds. No one in the room seemed to hate it. Let’s do this! The journey to QUERY. One of the primary authors of the RFC took us through what it took to make it happen. It was sixteen years since the most previous registered HTTP method and maybe this was the last one ever?

0 views

Workshop Basel day two

If you missed it. I already described day one . Caffeinated and ready, we all gathered in the same spacious room as yesterday, but seated in new places as “suggested” by our captain. Some of us even remembered to move over the name tags we wrote yesterday to our new seats. No time was wasted on introductions today. We dove straight in at the deep end. Is the future of software that we check-in the AI prompts in the git repository and trust it to generate the correct code? Are specifications the new level o f abstraction for source code? These questions triggered long discussions with a huge mix of opinions and experiences getting shared about how AI is used, should be used and could be used now and in the future. The Common Crawl spidering upgraded to using HTTP/2 for their scan and as an end result, I believe 61% of the responses used HTTP/2 and the entire round ended a few percent faster than before, which when you traverse a few billion URLs really makes a difference. They apparently use a locally patched version of Apache Nutch for this. The HTTP probe project runs a lot of tests on HTTP/1 servers and compares how they behave in a lot of different aspects and then generates these awesome tables. Looks like something for every server implementer team to have a look at and decide what of these red boxes that should rather be converted into green alternatives. HTTP Zoll is a new test suite for intermediaries that tests intermediaries (what we often call proxies) for a large amount of request and response smuggling issues. Some real world problems found were discussed and as this project aims at going Open Source words were expressed on what kind of precautions and checks that maybe should be done first. I hope we get to hear more about this project soon. The HTTP Arena is another project that does performance and measurements. They test HTTP server frameworks and present the results in various ways on their site. In this presentation , we were presented with different HTTP/3 deployment numbers from different sources and the associated reasoning around why they differ but then more importantly. what can and should be done to increase HTTP/3 usage.  Anti-virus interceptions, enterprise blocks and server-side performance not yet on par with TCP were mentioned as reasons for holding back the numbers. Reasons for using HTTP/3 include use cases that encourage QUIC adoption: WebTransport, Media over QUIC and MASQUE (HTTP/3 proxies and HTTP/3 proxies over older HTTP proxies).  Using HTTPS-RR for upgrade was promoted , as every alt-svc response that is returned with an ALPN using h3 should perhaps also offer h3 over DNS. Why doesn’t your server announce its h3 support over HTTPS-RR? QUIC v2 is deployed on an amazing 0.003% of all QUIC v1 domains and there was a discussion why this is so and the common sentiment in the room seemed to be that very few saw a reason for deploying v2 and several expressed a concern that doing so might in fact introduce issues. Someone (you can probably guess who) in the room increased that number a lot by quietly mentioning that haxproxy.org certainly supports it. QUIC multiplexing over bi-directional streams is a proposal on how to do QUIC-style multiplexing over TLS (or anything else really). It has been adopted by the IETF QUIC working group and there was a somewhat extended discussion about what the HTTPbis group should or should not do with it. The biggest interest might be for data center use, but is that then something IETF should bother about? This is not the first time I blog about this, and even if there did not seem to be a strong demand or need for this, it also did not seem to be completely dead. I bet we will hear more about this later. Doing a TLS terminating MITM proxy has its challenges and we were given some insights and experiences on the challenges of doing HTTP/2 and HTTP/3 to the server. The browsers refuse to do HTTP/3 when they detect custom CA certs installed, which apparently is mostly because of lots of past bad experiences with anti-virus software that in particular seems to break QUIC and for users it is not obvious where the blame should go. This then makes browsers not do HTTP/3 over any MITM proxy. Some time was spent on how allowing different clients to the proxy uses a shared h2 connection to the target server is complicated and not used, even though in theory it should be possible. An argument was made that it could even lead to worse performance than when using HTTP/1 but I could not quite follow that reasoning. I’m sure I missed some subtle detail in that explanation. When the afternoon is running late and we have been promised beer and snacks after the final talk, what is better than a hard core technical presentation with lots of graphs and numbers showing how QUIC performance can be improved by tweaking the congestion control algorithm and send more data in the startup phase of a new QUIC connections? This new approach is called Rapid Start and it looks like a promising and yet simple improvement. According to experiments done on real world traffic, the time to last byte was reduced by 14.7% on average. Not bad at all. Our meeting sponsor Adobe graciously sponsored drinks and food so we got to linger around for a few extra hours and talk even more HTTP and networking until the personal firmly insistent they needed us to leave the room and we instead continued solving world problems elsewhere. Topics around the table included the famous HTTP/2 spec coin flip, the QUIC spin bit, the SCONE situation for QUIC, the timeline behind the QUERY method and many more great stories. Thanks for the beer! Now we can’t wait for day three.

0 views

Detecting Full Table Scans With SQLite

I’m at RubyConf this week, and it’s great! I recently read that lobste.rs is now running on SQLite . One part from the post caught my attention: I wish we could say in a test, “Fail if you encounter any full table scans”. Which would have caught the perf issues we experienced during the first deploy. SQLite collects information about prepared statements and exposes those statistics though an API . The upshot of this is that we can tell whether a statement did a full table scan after executing the statement without using an . Here’s an example program that demonstrates detecting a query did a full table scan: Feels like we could integrate this in to Rails and warn or raise in test / development. I’m not sure if we’d want to check this all the time in production, but maybe it would be fine?

0 views
Unsung 3 days ago

Flickr’s optimistic committing

Somewhere next to optimistic loading and optimistic saving exists another technique to make apps feel faster: optimistic committing. Flickr is a great example. After navigating to photo upload, you enter a sort of a foyer where you can drag in the photos, reorder them, name and tag them, and otherwise prepare them before pressing the big Upload button. But Flickr also optimistically assumes you will press that button, and slowly starts uploading the heavy photos in the background the moment you drag them in. Like all optimistic schemes, being friendlier toward the user complicates things for Flickr’s designers and engineers. After all, there is still a regular upload modal after you do commit to the upload… = 2x) and (width >= 700px)" srcset="https://unsung.aresluna.org/_media/flickrs-optimistic-committing/2.2096w.avif" type="image/avif"> = 3x) or (width >= 700px)" srcset="https://unsung.aresluna.org/_media/flickrs-optimistic-committing/2.1600w.avif" type="image/avif"> …so the two states – quiet staging area upload, and the official visible upload – have to be reconciled and kept in sync. Also, optimistic but eventually cancelled uploads have to be cleaned up from the servers. Lastly, there’s signposting. Contrary to lighter optimistic loading schemes, which typically simplify reality by pretending no data transfer is actually happening, the optimistic committing here is actually visible through small indicators: I think this transparency is welcome. In the past, Meta (who else!) got into hot water for abusing optimistic committing : Did you ever record a video on Facebook to post directly to your friend’s wall, only to discard the take and film a new version? You may have thought those embarrassing draft versions were deleted, but Facebook kept a copy. The company is blaming it on a “bug” and swears that it’s going to delete those discarded videos now. They pinkie promise this time. In this context, it’s good that Flickr conveys data is being sent to the servers; I believe this helps with building trust. On top of transparency, I think it’s also good that this process shows the progress of uploading with a lot of precision – not just between files, but also within each file. Internet connection speeds vary so much, not just geographically, but also even situationally, that this is really helpful in practice. There are many moments where auto saving to the cloud needn’t bother the user unless the connection goes offline for a longer while, but this feels like a situation where clarity is better than magic. #details #loading states

0 views
Binary Igor 3 days ago

The Order of Data: defaults, performance, determinism & paging

How does the database decide on the order, when it is not specified? What about performance? Can returned pages overlap? Meaning: might item from page 1 suddenly appear on page 2, even if the underlying data stays the same?

0 views
Anton Zhiyanov 6 days ago

Go-flavored concurrency in C

Go's concurrency is one of the main reasons people like the language. You write , send values through channels, and the runtime scheduler runs thousands of goroutines on just a few OS threads. It feels effortless. None of that machinery exists in C. Which made me wonder: how close can you get to Go's concurrency model using only POSIX threads? Obviously, native OS threads can't match the efficiency of lightweight goroutines, but what is the actual cost, when does it become a problem, and is there any way to at least partially avoid it? I ran into these questions while adding concurrency to Solod (So), a strict subset of Go that translates to plain C, with no runtime and no garbage collector. In the end, I came to the conclusion that you can do quite a lot with pthreads — as long as you're honest about the tradeoffs. This post is about the POSIX threads-based concurrency model I chose, the benefits it offers, and its limitations. Mutex/Cond • Atomics • Pool • Channel • Performance • Design • Wrapping up Everything in So's concurrency stack is built on two basic POSIX primitives: the mutex and the condition variable. is a thin wrapper around : Since So translates to C, this is basically a struct that holds a and a function that calls . Here's the transpiler output: That is the whole translation — the generated C is a near-mechanical mirror of the So code, only noisier. From here on, I'll mainly show the So version, but I'll also provide the C code for those who are interested. There's nothing exciting here: is a pthread mutex wrapper that panics if something goes wrong (which is rare). The companion primitive is , a wrapper around . It's the standard "wait until a condition holds" tool, associated with a mutex: These two types — and — are the foundation. Other concurrency tools — , the thread pool, channels — are built using a mutex and one or more condition variables. This has several effects on performance, as we'll see later. Not everything needs a lock. So's mirrors Go's: , , , , , and a generic , all with , , , and methods. The nice thing is that these don't need pthreads at all. They map directly to the C compiler's builtins — the same hardware instructions that Go's compiler emits. So there's no reason for them to be any slower, and they're not: Each number is the cost of one operation on a single thread. is a good example of using atomics effectively. Its fast path only needs a single atomic load — after the given function runs, every future call to checks a flag and returns: To actually run code concurrently, you need threads. The type wraps and its related functions: Consider this function: Usage example: It might look like , but that's just on the surface. starts an actual OS thread, not a goroutine. You have to eventually call to join or it, or else its resources will leak. Also, OS threads are expensive to create — they're nothing like Go's goroutines, which only need a few kilobytes of stack and start up in nanoseconds. That's exactly why you usually don't want to call inside a loop. For tasks that are short-lived or happen often, it's better to use a pool of long-lived worker threads and send tasks to them. to the rescue: Usage example: The first argument to , , is a memory allocator. Solod avoids hidden allocations, so anything that needs memory takes an allocator explicitly — here it backs the pool's task queue. Under the hood, a is a fixed group of worker threads that pull tasks from a shared queue (a ring buffer). It uses one mutex and a few condition variables: wakes up a worker when there are tasks to do, applies back-pressure when the queue is full, and lets know when everything is finished. It's a classic producer-consumer setup, about 200 lines of code , and there's nothing fancy about it. The heart of the pool is the worker loop. Each thread blocks until a task appears, runs it outside the lock so workers execute in parallel, then records that it finished: This is what separates a pool from a plain queue. bumps as it enqueues; each worker decrements it after running a task, and the last one out broadcasts . sleeps until the count hits zero: The tradeoff is that the number of worker threads is fixed. In Go, a program can handle thousands of concurrent I/O waits because blocked goroutines use very little memory. A So pool can't do this — if all N workers are parked on a blocking syscall, the pool is stalled until one returns. You have to set the pool size based on the workload, instead of letting the runtime manage it for you. Channels are an important part of Go's concurrency model, and So's gives you something quite similar. Just like in Go, it passes values by copy and comes in buffered and unbuffered flavors: is a thin generic shell over one of two engines, picked at creation time: Buffered ( ) is a mutex-guarded ring buffer with and condition variables — like the queue. Senders block when it's full, receivers block when it's empty. The full implementation also checks for , but I left it out for brevity. is the mirror method: block while empty, pop the next value, signal to wake a sender. It also handles the closed channel, returning once the buffer is closed and drained. The rest is this lock-wait-signal core. Buffer source code Unbuffered ( ) is a rendezvous: each send blocks until a receiver takes the value, copying bytes directly from the sender's stack to the receiver's destination without using an intermediate buffer. is the other half: it waits for a published, unclaimed value, copies bytes straight from the sender's stack into (no intermediate buffer), marks it as claimed, and broadcasts to wake the sender back, creating wakeup #2. One hand-off, two wakeups. Copying directly from the sender's stack is safe because of that second wakeup. is a pointer to , which lives on the sender's stack. While the receiver is reading it, the sender is parked in , so its stack frame stays alive. The sender only returns (and reclaims that memory) after the receiver sets and wakes it up. There's no need to copy into a shared buffer because the source is guaranteed to outlive the read. Rendezvous source code As you can see, the API is pretty similar to Go. Now let's look at the numbers. Here's the main tradeoff: pthread-based concurrency primitives are fast when no one has to block, but they get slow when someone does. And it's always for the same reason. Go schedules goroutines in userspace. When one goroutine blocks on a channel and another wakes it up, the runtime moves them between its own queues — no kernel involved. POSIX threads, on the other hand, don't provide a userland scheduler. When a thread blocks on a condition variable, it parks in the kernel, and waking it up requires a syscall. Every hand-off between threads that actually parks pays the cost of a syscall on both ends. You can clearly see the difference in the mutex benchmarks. With 8 competing threads, it all comes down to whether the waiting threads have to park or not: Each number is the average time for a single / pair. The uncontended benchmark runs on one thread, while the contended benchmarks have multiple threads fighting over the same mutex. Notice that So actually wins the first two benchmarks, and for good reason. So's is a plain call with nothing extra, while Go's adds more overhead — like starvation-mode tracking and a runtime that stays involved because a goroutine can be preempted in the middle of a critical section. When nobody parks, that overhead is the main cost, and the thinner wrapper is closer to the hardware. With an empty critical section (the spin benchmark), a waiting thread grabs the lock while still spinning and almost never parks — So wins by 2.8x. The uncontended benchmark (a single thread, no contention) shows the same thing: less code between the call and the lock, so 9ns versus 14ns. The picture flips the moment threads have to park. Give the critical section about a microsecond of real work (the work benchmark) and waiters exhaust their spin budget and park. Now every hand-off costs a wakeup syscall, and So drops to half of Go's throughput. The work is identical in both cases — the difference comes from the parking cost. Condition variables demonstrate this clearly because they always park: Each number is the cost of one rendezvous round: a single broadcast that wakes every waiter and hands control back, with N waiters plus one broadcaster. Pthread-based condition variable is consistently 7-10 times slower. There's no trick to close this gap — it's just the cost of waking up a real OS thread instead of a goroutine. Channels have the same issue because they're built using mutexes and condition variables: Each number is the cost of moving one value through the channel (send plus its matching receive). The number in parentheses is the buffer capacity. The uncontended case fills and drains a buffer from a single thread, so nothing ever blocks — it's just a lock plus a copy, which gives So a slight advantage. But the moment a producer and consumer actually start handing off work, So has to wake up a thread for every transfer that gets parked. It's worst for the unbuffered channel, where every value is a rendezvous with two wakeups: 23x slower. A larger buffer helps a lot — with room for 100 items, most sends go through without waking anyone, and the gap narrows to about 2x. The consequence is that the larger your tasks are, the better pthread-based concurrency works. If you use a channel for fine-grained, value-at-a-time streaming between threads, performance will suffer. But if you use a channel to pass whole work items to a pool, where each item takes tens of microseconds to process, the wakeup cost becomes negligible. The pool benchmarks on realistic workloads confirms this: Each number is the wall-clock time for 8 workers to process the whole batch. Here, So is within 1.1x of Go. The per-task dispatch cost is still present, but it's spread out over real work, and the performance penalty is pretty small. Benchmarking All benchmarks were run on an Apple M1 CPU running macOS. The C code was compiled with Clang 16 using these CFLAGS and mimalloc as the system allocator: The results shown are the medians from several benchmark runs. Each benchmark ran many iterations, following the same logic as Go's own benchmarking. The Go benchmarks used Go 1.26 and . Source code for both So's and Go's benchmarks: conc • sync Here's a summary of the strengths and weaknesses of the pthread-based approach: If you're looking for "thousands of cheap goroutines", the pthread-based approach will let you down. But if you're fine with "a few worker threads handling lots of tasks", it holds up well. Three decisions influenced the way I implemented concurrency in Solod. Pthreads, not fibers . I know there are coroutine/fiber libraries for C that avoid the kernel wakeup cost — single-threaded ones like neco , and multi-threaded ones like libfiber . A userspace scheduler is exactly what would help to match Go in the benchmarks above. I decided not to use one. I wanted something dead simple — an approach I could explain in a paragraph, using tools every C programmer already knows. The trade-off is that you lose some performance with fine-grained blocking, but in many real-world situations, pthreads work fine if you use a worker pool. For me, keeping things simple is more important than saving a few microseconds during task hand-offs. For now, at least. Standard library, not language . Go bakes goroutines, channels, and select right into the language. I decided to keep everything in the stdlib for two reasons. ➀ It follows So's "no hidden allocations" rule. In Go, quietly allocates a goroutine stack, and allocates a buffer. In So, all allocations are explicit: you pass an allocator to and , and you always know exactly where the memory comes from — whether it's the system allocator, an arena, or something else. ➁ A library is more flexible. Since a pool is a regular value, you can have as many as you need, each sized for its specific purpose. In a multi-stage pipeline where each stage needs a different capacity, you can start one pool per stage, each with its own and , instead of being given a single global scheduler. The language stays simple, and the flexibility is in code you can easily read. Timeouts, not select . Go's waits on several channel operations at once and proceeds with whichever is ready first. Implementing it would require a lot of work — a thread has to register interest on multiple channels, block once, and then wake up when any of them is ready — so I left it out. Instead, offers and , which cover two common uses of with a single channel: What's missing is the ability to block on multiple channels at once and continue with whichever one is ready first, as well as the option to mix sends and receives in the same selection. How close can you get to Go's concurrency using only pthreads? Close enough to be useful, but not enough to really match Go. You can wrap real OS threads with familiar APIs — mutexes, condition variables, pools, channels — and the code will look and act a lot like Go, at least until a thread needs to block. But there's no scheduler underneath, so when a thread blocks, it's an actual thread waiting in the kernel, not a goroutine that's paused for free. That's the main limitation of this approach. What you get in return is brutal simplicity. Every primitive is a thin wrapper with no runtime hiding behind it, so the performance is exactly what the OS gives you: fast atomics, fast uncontended locks, and pooled throughput within ~10% of Go on coarse-grained work. But as soon as you switch to fine-grained, one-value-at-a-time hand-offs, the cost of kernel wakeups becomes the main factor, and you'll notice the slowdown. If you think the pthread approach might work for you, I invite you to try Solod . It includes the and packages, along with many others ported from Go's standard library. ➕ Coarse-grained pooled workloads are within about 10% of Go's performance. ➕ Uncontended locks and spin-friendly critical sections perform quite well. ➕ Atomic operations are as fast as in Go. ➕ The implementation is 100x simpler. ➖ Anything that needs to park and wake an OS thread is much slower than Go's userspace scheduler. ➖ The pool can't handle thousands of blocked waiters like goroutines can. "Do this, but give up after a while" (Go's idiom). "Do this only if it won't block" (Go's non-blocking branch).

0 views
Jack Vanlightly 1 weeks ago

Apache Kafka performance #1 - linger.ms

This is the first in an ongoing ad-hoc series of posts on Apache Kafka performance. I have no general direction, I’ll just share interesting insights based on the performance testing I do on Apache Kafka. Recently I was curious to see if there was any general performance improvement since Kafka 3.X. So I ran a suite of benchmarks with Dimster against 3.7.2 and 4.3.0. I saw two common patterns: Pattern 1: Low load benchmarks showed that end-to-end latency was higher with Kafka 4.3 compared to 3.7.2. The following is a 45 minute no-record-key workload of 5000 record/s, 20 topics (120 partitions), fan-out 2 (240 consumers), full TLS, on 3 brokers each allocated 8 SMT CPUs in k8s (on my Threadripper 9980X). Fig 1. Low load: end-to-end latency over time (p99 over 10 second intervals) Pattern 2: On more stressful loads, 3.7.2 would show much more spiky end-to-end latency compared to 4.3. The following is for the same workload at 100K records/s (200K out). Fig 2. High load: end-to-end latency over time (p99 over 10 second intervals). Kafka 3.7.2 showed large latency spikes. Fig 3. High load: End-to-end latency distribution It seemed that somewhere between 3.7.2 and now, big performance gains had occurred. Then my subconscious kicked in and reminded me that at some point in that period, the default had been changed from 0 to 5 ms. This would correlate with the low-load end-to-end latency result. The producer config controls how long the producer is willing to wait before sending a non-full batch (controlled by ). If a batch reaches first, it can be sent earlier. The point of is simple: give more records a chance to accumulate into the same batch, because larger batches are more efficient than many tiny batches. The important quantity is the rate “per producer, per partition” (rather than the aggregate rate). Kafka producers build batches per partition, so a producer sending 1,000 records/s to one partition has very different batching behavior from a producer sending 1,000 records/s evenly across 100 partitions. A rough way to reason about it is: For example, with a per-producer-per-partition rate of 100, we might expect 6 records per batch. This is only an approximation as it ignores arrival jitter, partition skew, batch.size config (default 16KB), compression, in-flight request limits, and broker backpressure. But it is good enough to build intuition. In the 5K records/s workload, each producer was sending about 41 records/s: That is one record every: This was also a no-record-key workload. With the default partitioning behavior, records from a producer tend to stick to one partition for a while before moving to another sticky partition. So, for batching purposes, the producer was usually sending roughly one record every 24 ms to its current sticky partition. That makes unlikely to help. A 5 ms linger is much shorter than the ~24 ms average gap between records, so most batches still contain a single record. To reliably get more than one record into a batch, the linger would need to be on the order of the inter-arrival time (tens of milliseconds), not 5 ms. So the low-load result made sense: Kafka 4.3’s default added a little extra waiting causing a higher end-to-end latency, but did not create meaningfully larger batches and its load was so low that larger batching wouldn't have helped anyway.  The 100K records/s workload was different. There, each producer was sending about 833 records/s: That is one record every: At that rate, can make a real difference. A producer has time to collect several records before sending a batch. In this workload, I saw the average batch size reach about 5 KB, or roughly five 1 KB records per batch. That reduced the number of small produce batches the cluster had to process. It also improved downstream efficiency for the brokers and consumers. The result was a large reduction in tail latency:  the 3.7.2 run, with the old default , had periodic p99.9 spikes around 700 ms,  while the 4.3.0 run, with the new default , had a much lower and more stable p99.9 around 8 ms. So the benchmark was not necessarily showing a deep Kafka 3.7.2 versus 4.3.0 performance difference. A large part of the effect could be explained by one client-side default changing: linger.ms moved from 0 to 5 ms in Kafka 4.0. I decided to run a similar benchmark again, explicitly setting linger rather than using defaults. This time I used half the producers (better for batching) but with record keys (much worse for batching). I ran Dimster on Kafka 3.7.2 (broker and clients) and 4.3.0 (broker and clients), with six test points across two scenarios: If we look purely at the batching behavior, none of the linger values helped in the 5K records/s tests as the per-producer rate coupled with record keys meant that linger was ineffective at creating larger batches due to the low per-producer-per-partition rate. The chart below shows Kafka 4.3.0 over the three test points with linger of 0, 5 and 20. Only a linger of 20 slightly moved the needle. Fig 4. 5K workload. Batch sizes across lingers 0, 5 and 20 The exact same pattern occurred with 3.7.2. This workload did not need larger batches: the latency distribution for linger.ms=0 was already good. There was no difference in performance between 3.7.2 and 4.3.0. Fig 5. 5K workload, end-to-end latency distribution The place where linger mattered was the 100K records/s keyed test. In that workload, showed a massive improvement over a linger of 0 and 5. Fig 6. 100K workload: end-to-end latency distributions for lingers of 0, 5 and 20 did not help much at all and we can understand why by doing the math: Due to record keys, A simple estimate would predict about two records per batch at and about six at , which lines up with the observed producer batch-size metrics below: Fig 7. 100K workload. Batch sizes across lingers 0, 5 and 20 The batching improvement with was reflected in the end-to-end latencies, with p99.9 of only 23 ms, compared to over 700 ms for a linger of . Noteworthy is that the results for 3.7.2 and 4.3.0 with were essentially identical. 4.3.0 pulled ahead in the lower lingers, but there is often huge variance in the higher latencies, so from one run, this is inconclusive. Don’t over-index on this one set of benchmarks. No benchmark is fully generalizable, and the right value depends heavily on the workload. The main takeaway is simply this: pay attention to producer batch sizes. When producers are sending batches with only one record, Kafka can hit performance limits much sooner than you might expect. The broker has to process more produce requests, more record batches, more replication work, and more fetch-side batch metadata for the same logical throughput. A small amount of batching can make a large difference. The most important number to understand, with regard to likely batch sizes, is the per-producer-per-partition send rate. Total cluster throughput can be misleading. A workload doing 100K records/s may still produce tiny batches if each producer is spreading records across many partitions. Keyed workloads are especially prone to this, because the key determines the destination partition. If each producer writes to many keyed partitions, the effective rate into each producer-partition pair may be low. Under enough load, Kafka producers will often start batching more even with a low , simply because the sender thread cannot drain records immediately. Broker latency, network saturation, throttling, or in-flight request limits can all cause records to accumulate in the producer. But relying on backpressure to create batching is not ideal. In some workloads, setting a higher lets you get the batching benefit before the system is already under stress. The default changed from 0 to 5 in Apache Kafka 4.0. That means some Kafka 4.x client upgrades may show performance improvements simply because the producer is now batching more by default. Conversely, if you are using Kafka 3.x clients, explicitly testing is a low-risk experiment. As for Kafka 3.7.2 versus 4.3.0, anecdotally, I’ve seen improvements in Kafka 4.x, and I may do more benchmarking to isolate those changes. the 3.7.2 run, with the old default , had periodic p99.9 spikes around 700 ms,  while the 4.3.0 run, with the new default , had a much lower and more stable p99.9 around 8 ms.

0 views

Accelerating Stream Processing Engines via Hardware Offloading

Accelerating Stream Processing Engines via Hardware Offloading Zhengyan Guo, Mingxing Zhang, Yingdi Shan, Kang Chen, Jinlei Jiang, and Yongwei Wu SIGMOD'26 This paper describes a trick to offload partitioning from CPU to NIC via clever use of RSS. The context of the paper is distributed systems for processing streaming queries, but the trick seems applicable to databases in general. Hash partitioning is a common divide-and-conquer technique to implement joins and aggregations. Here are some posts about papers that use this partitioning: SPID-Join: Skew-Resistant In-DIMM Joins Breaking Through the Memory Wall of OLTP Systems with PIM High-Performance Query Processing with NVMe Arrays: Spilling without Killing Performance Efficiently Processing Joins and Grouped Aggregations on GPUs RSS is a NIC feature whereby the NIC hashes select fields from incoming packet headers and uses the result to determine which CPU core to send the packet to. This enables efficient load balancing across CPU cores without reordering packets within a given flow (i.e., connection). Here is a previous post describing a clever way to extract more value out of RSS in cloud VMs: Enabling Fast Networking in the Public Cloud If you have many nodes cooperating to process a query, then the hash partitioning may span many nodes. For example, node A could hash the join/aggregation key of each row and then forward the row to either node B, C, or D, E depending on the hash value. This enables the join/aggregation work to be split across nodes B, C, D, and E. This is all fine and dandy from the perspective of node A. However, nodes B, C, D, and E likely have multiple CPU cores. How can one of these nodes execute their join/aggregation in parallel? The answer is recursive: partition the incoming rows again (using a different hash function) and have each CPU core process one of these smaller partitions. The paper focuses on the cost of partitioning the dataset, which can cost just as much as the partition join/aggregation step that follows it. The key insight is that the partition algorithm looks a lot like the RSS load balancing algorithm present in the NIC hardware. Here is the punchline: establish multiple network connections (using different ports) between node A and each of nodes B, C, D, and E. When node A partitions rows, it determines a specific connection (not node) to send each row to. This doesn’t improve performance at the sender, but it dramatically helps the receivers. Each receiver configures RSS such that all connections are spread across the CPU cores on the receiver. The NIC then distributes received packets to the appropriate CPU cores without any partitioning work on the receiving nodes. The one downside to this approach is load imbalances that occur due to data skew. If some join/aggregation keys are more common than others, then some CPU cores may be assigned more work than others. The paper proposes to dynamically monitor load imbalance at each receiver and reconfigure the RSS settings of the NIC to move connections off hot cores. Section 5 of the paper describes synchronization necessary to move a connection between cores in the middle of the query. This is a good mitigation, but as we’ve seen in this paper , RSS configuration is not uniformly exposed on cloud VMs. Fig. 8 has performance results across a number of benchmarks: Source: https://dl.acm.org/doi/10.1145/3769754 Dangling Pointers The solution is great, but asymmetric. I wonder if there is a way to get similar benefits on at the sending node (send side scaling)? Thanks for reading Dangling Pointers! Subscribe for free to receive new posts.

0 views
David Dodda 1 weeks ago

Why Don’t Websites Put All Their Images Into One Giant JPEG? (Nerd-Sniped by My Brain)

I had a simple question: Why do websites load lots of individual images instead of stitching them into one giant image and cropping out the pieces they need? At first glance, an image atlas sounds great. Instead of this: You create this: Then each UI tile crops a specific region from the atlas. That would mean: fewer network requests images arrive together no staggered popping maybe better perceived loading maybe less request overhead Not a new idea by any means. Games and UI libraries have used sprite sheets and texture atlases forever. The question is: why isn’t this the default for websites? I compared three approaches: Individual optimized images 14 separate optimized JPG files rendered as normal elements Canvas atlas one stitched atlas JPG each tile rendered by cropping from the atlas into CSS background atlas one stitched atlas JPG each tile rendered with , , and The atlas was regenerated from the same optimized images, so the comparison was more fair. NOTE: I ran the experiment by hosting it locally. so all the number you see are when you have the application served using a python server running locally. If you want to poke at it yourself, the experiment is live here: https://daviddodda.com/experiments/img-atlas/ note: make sure you disable cache. try each version a couple of times. I focused on three headline metrics. How many bytes were downloaded? When did the last required image resource finish downloading? When was the image grid actually ready to see? This last one matters because network completion is not the full story. The browser still has to decode images, rasterize, paint, composite, and show pixels. On a remote machine running Chromium, all files hosted locally, 10 runs each: The surprising result: The CSS background atlas was the fastest to visible. The atlas had a clear network advantage: Well, one larger request has less overhead than many smaller requests. This effect is especially visible when the server/browser are using less optimal connection behavior. In my test, Chromium reported for the local server, so request overhead was more obvious than it would be under HTTP/2 or HTTP/3. With modern HTTP/2 and HTTP/3, many individual image requests are less painful because requests can be multiplexed over one connection. But request overhead still exists. The individual images transferred: The regenerated atlas transferred: Because an atlas is a rectangle. Real images have different aspect ratios. When you pack them into one big rectangular sheet, you often create empty space. In my case: That is about 31% extra pixel area. So even though the atlas used one request, it transferred more data and required the browser to decode a bigger image surface. The canvas atlas looked like it should be fast (thought modern hardware was fast enough). It loaded one atlas image, then cropped each tile into a canvas. But the results were poor: The breakdown showed: The actual JavaScript canvas drawing was not expensive. The expensive part was making all those canvas results visible. That means the bottleneck was not: It was the browser’s later paint/composite work. The CSS background atlas used normal DOM elements: This was much faster: The breakdown: The decode cost was still there. But paint/composite was dramatically better than the canvas version. So if you are going to do image atlasing in normal web UI, CSS backgrounds may be much better than drawing many cropped canvases. They are great for: emoji sheets game textures small repeated UI assets known fixed-size tile sets maps or tile-like interfaces cases where all assets are needed immediately They are less great for: photo galleries blog images user-generated content responsive images content-heavy websites long scrolling pages frequently changing assets now, don't go getting any ideas about rewriting your website's image pipeline to use image atlas. here are some reason why it's a really bad idea. With individual images, the browser can load only what is needed: With a giant atlas, loading one image means loading everything in that atlas. That is great if you need everything immediately. It is terrible if the user only sees 5% of the images. The web has powerful responsive image tools: The browser can choose the right image for the device, viewport, DPR, and network. With a giant atlas, this becomes much harder. You may need multiple atlases: The combinatorial complexity gets ugly quickly. Atlases require packing. Packing creates waste. If the images have different shapes, the atlas may contain a lot of empty or unused area. Even a good packing algorithm cannot always avoid this. In my test, the atlas had about 31% more pixel area than the individual images. With individual images: Only that image needs a new URL/cache entry. With an atlas: The whole atlas cache is invalidated. That is bad for websites where content changes often. Browsers are good at prioritizing resources. The hero image can be high priority. Below-the-fold images can be lazy. Tiny thumbnails can wait. With a giant atlas, everything has one priority. You cannot easily say: The atlas is all-or-nothing. A compressed JPG might be 2 MB on the network, but decoded pixels are much larger. Decoded RGBA memory is roughly: A large atlas can become a huge decoded surface. In my first broken atlas attempt, the atlas was: That is around: Even if the file downloads quickly, that is a lot for the browser to decode, rasterize, and paint. An has natural semantics: A CSS background image is decorative by default. If the image is meaningful content, you need to rebuild semantics with ARIA or hidden text. That is doable, but it is extra work and easier to get wrong. Browsers have spent decades optimizing: If you use an atlas, you bypass some of that machinery and take on more responsibility yourself. Sometimes that is worth it. Often it is not. Every approach has its niche use case (shocker). My brain nerd-sniped me into exploring and writing about this. It was fun seeing the cute animals load in though. fewer network requests images arrive together no staggered popping maybe better perceived loading maybe less request overhead Individual optimized images 14 separate optimized JPG files rendered as normal elements Canvas atlas one stitched atlas JPG each tile rendered by cropping from the atlas into CSS background atlas one stitched atlas JPG each tile rendered with , , and emoji sheets game textures small repeated UI assets known fixed-size tile sets maps or tile-like interfaces cases where all assets are needed immediately photo galleries blog images user-generated content responsive images content-heavy websites long scrolling pages frequently changing assets

0 views

My side quest measuring input latency with VK_EXT_present_timing

There’s been two use cases I’ve been looking at recently where having an objective and accurate metric for input latency is important. One is the push for AMD_anti_lag support in Mesa (which I need to get around to reviewing) where we need solid objective data that it’s actually helping, and also for my streaming solution PyroFling, I want some hard objective data demonstrating where the milliseconds are going. I’ve been working on lots of plumbing in this area recently to hopefully help the ecosystem. We shouldn’t need weird hardware solutions to do this stuff. With VK_EXT_present_timing now being plumbed through the Linux driver stack, we have the API we need to do comparative analysis without too much fluff. I added a new layer to PyroFling repo here . I documented how it works there, but the basic gist is to read back a small region of the swapchain and compare that to a previous frame to compute a Mean Square Error (MSE) metric. When this error spikes significantly compared to the previous N frames, we assume it happened due to input. This input is synthetically generated with /dev/uinput at somewhat random points in time. Using present timing we get accurate metrics for when that present flowed through the system and we can infer latency metrics based on when we generated synthetic input and when the different frame hit the screen. While the layer is active, the center of screen shows an “error” image. Before starting a capture, this square should be mostly black. TAA jitter on a stable scene can be seen in this view too, and that’s fine for this layer. A small delta input is generated which should show up as large deltas. E.g. if I move the camera while taking a screenshot: After a run, we can do analysis. This is a CPU bound game on my lopsided system with 9070xt and an old Zen2 CPU. All numbers look just like I expect. To stress test anti-lag a bit, Cyberpunk 2077 with RT is a good candidate since it completely slams my GPU at native-res + heavy RT: Some TAA instability comes through in the delta box. Without anti-lag, we see the culprit right away: A full 2 frames of GPU latency, which is bad. We submit work to the GPU long, long before it goes idle from previous frame, oversubscribing it massively. This is what Reflex/AntiLag attacks, adding delays such that we barely keep the GPU fully subscribed, but no more. With anti_lag it looks much better: The rest of the latency can be explained by latency introduced in the game: For these tests I forced my monitor to 60 Hz FRR. With vsynced output, we usually get issues with too much FIFO buffering causing latency. We can measure that too. For this I used my own Granite test scene, since I know exactly how it should behave. I have a ground truth to compare against. The default in Granite is that there’s a maximum of 1 outstanding present, giving roughly 2 frames of latency using vkWaitForPresent2KHR . If I use the mode to block on previous frame completing (no GPU <-> CPU overlap), we get 1 frame of latency as expected with VK_KHR_present_wait2 : Latency between input signal and QueuePresentKHR is as expected a little over half a frame: Sometimes, we can spin very hard on MAILBOX, yet there is still some latency since the screen only updates so often. In the above test, I was seeing in windowed mode on KDE Wayland: We expect to land roughly in the middle of the frame cycle here. For cases where tearing is supported in IMMEDIATE, we’d expect this delay to be effectively 0, or very close to 0. Another motivation for this layer was to determine input latency when streaming. The basic idea is simple, which is to run the layer on the client instead, generate synthetic inputs on client (which get sent over to game), and we should be able to measure latency exactly the same way. In the baseline, I used the Sponza scene in Granite on a 180 Hz VRR monitor. I frame limited to 60 Hz, which is how I stream. This eliminates FIFO buffering latency and overall latency will depend on polling latency and GPU times since VRR should be working optimally. The overwhelming majority of this latency is caused by 0.5 refresh rate delay for polling input. Here I attempt to measure the added latency for doing IPC, encoding on GPU using the codec I created , sending that data over localhost UDP, decoding that in pyrofling-viewer and getting it on-screen. I tested with 2560x1440p60 at 250mbit with 4:4:4 YCbCr. Adding all these steps account for about 1 ms of extra lag when the client is running on a VRR monitor. Running the timeline trace in pyrofling server, we can see that it’s actually “network” overhead that accounts for the vast majority of this millisecond overhead. My networking code is likely not very optimal since I’m just hammering the plain sendmsg/recvmsg APIs here, but a real world network scenario is probably going to be bandwidth bound taking a few milliseconds to pump through the packets. With FFmpeg NVENC 10-bit 4:4:4 at 50 mbit on an RTX 4070, it looks a bit rougher: Now the overhead is mostly concentrated in overhead for encoding (> 6 ms) and decoding (a few ms) instead. Going to 4K, the overhead increases yet again by several milliseconds … Overall, it becomes a question of which overhead is greater, HW encode and decode time, or network bandwidth. PyroEnc via Vulkan video also shows similar overhead numbers, so not sure if there is room to improve the encoding performance here. One would imagine the FFmpeg path for NVENC to hit the fast paths of the hardware if there is one. RADV performance for H.265 is much better at least, but this was with 4:2:0 since I haven’t wired 4:4:4 up in PyroEnc at the moment. Still, from what I understand, AMD doesn’t support 4:4:4 encoding anyway, so what you gonna do … While the existing infrastructure for anti-lag is designed for GPU oversubscription, there isn’t much for keeping FIFO latencies in check. On a VRR gaming monitor, these concerns are largely irrelevant, but for e.g. remote streaming or running on less hardcore-gamery setups, 60 Hz FRR is still relevant. I’ve been experimenting a bit with a low latency FRR pacer in Granite. It aims to calibrate the rendering loop such that GPU goes idle with just enough headroom to hit the compositor deadline for a flip. This approach isn’t particularly reliable for a game with a highly variable load, but for e.g. retro emulation or streaming the GPU load is quite small and stable. In my present-timing test app in Granite, there’s a path to test this. It’s not super stable right now, but using EXT_present_timing to slowly tune in a tight loop is a potential use case for the extension. The basic gist is that we can discover compositor grace periods by checking queue completion times versus expected flip times. If GPU was done before the expected refresh, yet we still missed the cycle, we can estimate that our grace period was too tight, and increase the gap. If we gain confidence on our current gap, try to lower, etc. Stability should improve over time where we ideally land on a stable gap that basically never fails to meet deadline. This is likely similar in spirit to what compositors do internally. This approach is probably more stable on KHR_display than Wayland or X11 since we’re not fighting against two layers of flip deadlines. For a more practical game application, it might work better to tune the loop so that the GPU goes idle at the half cycle before flip or something conservative like that, instead of trying to race the compositor. Hopefully the layer will be a useful addition to the ecosystem. It’s also a fairly standalone sample of how to use the present timing extension for feedback purposes.

0 views
Lalit Maganti 2 weeks ago

On "When impressive performance gains do not matter"

When impressive performance gains do not matter is a very nice article covering some ways in which going after performance alone is not sufficient without considering the wider picture. It resonated a lot with how I think about performance. If there are multiple bottlenecks in the pipeline—and with these systems, this is common—the overall throughput will not improve until every last bottleneck is removed. His focus is on distributed systems bottlenecks, but I’ve hit the same “do-nothing” speedups when optimizing client side programs. Usually this comes from spending a lot of time thinking something was the bottleneck when it wasn’t. CPU profiling is where this bites me most: it tells me “function X is taking 30% of the cycles” and I think “oooo, there’s a lot of gains to be made there”. I build a microbenchmark for X, optimize it and there’s only a marginal gain at the high level. While disappointing, I’ve become used to it over time and internalized that performance is highly non-linear and actually knowing where the problem lies is really hard.

0 views
Jeff Geerling 3 weeks ago

Framework's 10G Ethernet module exposes USB-C's complexity

I've been following WisdPi's development of various 5 Gbps and 10 Gbps Ethernet adapters for the past couple years. They use newer Realtek Ethernet chips, which sometimes have performance quirks—most frequently encountered under Linux. In today's video, I tested the new WisdPi 10G Ethernet Expansion Card for Framework computers. It fits in any available Framework Expansion slot—even on the Framework Desktop. But Expansion Cards use USB-C for their connection to the mainboard—and therein lies the rub...

0 views
Jack Vanlightly 3 weeks ago

Kafka Share Groups - Pathological fetch waits with record_limit

In this post we’re going to see how combined with: fewer consumers than partitions and various cases of “partition skew” …can result in subpar performance with share groups.  I stumbled on these issues when running large sets of dimensional tests with Dimster’s explore-limits mode, which finds the highest sustainable throughput while staying within a target end-to-end latency target. There was a specific subset of the tests that explore-limits mode would consistently fail to complete, and they all happened to be with record_limit and a consumer count lower than the partition count. In this test, we’ll understand why Dimster had such a hard time with this combination. Kafka share groups have two methods of acquiring records: I already explained the difference in Kafka Share Groups and Parallelizing Consumption - Part 2: Producer Batches and share.acquire.mode but let’s just cover it again. Share consumers are assigned partitions as part of the share group protocol. It works similarly to the consumer group protocol, except that multiple consumers can be assigned to the same partition. With , share consumers acquire records in whole batches, using max.poll.records as a soft cap. Furthermore, a share consumer assigned multiple partitions across multiple brokers will send fetch requests to each of those brokers, concurrently. With , share consumers acquire records as slices of batches, where the size of the slice is determined by (now a strict cap). If you set but the relevant batch contains 32, then only a slice of 10 records is acquired (though the whole batch is transmitted over the wire). Furthermore, a share consumer assigned multiple partitions across multiple brokers will send fetch requests round-robin (one-at-a-time) across those brokers. Each time you call poll, it will fetch from the next broker. Dimster consistently did not complete explore-limits tests with and fewer consumers than partitions. The issue is that during various phases of an explore-limits test, lag can build very quickly if producers shoot past the capacity of the consumers. Dimster sees this and attempts to drain the lag before it resumes with a lower producer rate. Fig 1. Dimster’s explore-limits mode regularly drains a backlog while searching for the highest sustainable rate under a target e2e latency The drain works by pausing the producers, temporarily removing any consumer processing time (if configured) and then resuming with a lower producer rate. However, with and fewer consumers than partitions, this lag drain would basically stall as the consumption rate would end up just a trickle (such that it would take hours to drain the backlog that had accumulated). So I ran some backlog drain tests to understand what was going on and discovered what I’ll refer to as pathological fetch waits . Imagine one share consumer and a topic of 10 partitions spread across 3 brokers. Imagine if all the producers sent records to only one partition, leaving the other 9 consistently empty. What sub-optimal share consumer behavior might we see? Let’s go through it. Remember, with , fetches to brokers are round-robin if a consumer is assigned multiple partitions (on different brokers): Consumer sends a fetch to (which hosts partitions 0, 3, 6, 9) and gets back some records for partition 0. Poll is called again, triggering a fetch to (which hosts partitions 1, 4, 7), but there are no records. Poll is called again, triggering a fetch to (which hosts partitions 3, 5, 8) but there are no records. Poll is called again, triggering a fetch to , returning more records of partition 0. So what’s the problem? Can you see it yet? The problem is . It defaults to 500. Yes that’s right, steps 2 and 3 took 1 second to complete and returned no records! 1 second where nothing is getting consumed, while partition 0 continues to receive records. Fig 2. A single consumer, doing round-robin fetches across 3 brokers, does a lot of waiting when encountering brokers with 0 lag across their partitions (leader replicas). Let’s run some benchmarks to understand how serious this issue can be. Setup: 1 topic, 10 partitions, 5 consumers, max.poll.records=500 (the default), backlog size 400M records. This test generates a 400M record backlog using the , which generates a relatively balanced load across partitions. Each record is 50KB, resulting in a 20 GB backlog. The coordinator logs the drain progress: By the time the test reached the short test timeout, consumption was about 3,900 records/s, from a high of 1.2M records/s (no simulated processing time was configured). 98% of the 400M backlog drained in around 8 minutes. The consumption slowdown started when lag was around 9M records. Extrapolating based on 3900 records/s, it should have taken 6 hours more to drain that 2% of the starting backlog.  What has happened is that due to some skew, half the partitions had drained causing the slow down. With 5 consumers and 10 partitions, each consumer was assigned two partitions, most likely on different brokers. So half of each consumer’s fetches were waiting for 500 ms and return nothing. The aggregate skew was relatively minor (the lightest partition had 39M and the heaviest had 45M), but the lag skew got worse as lighter partitions were drained.  A 400M backlog is an extreme case. But we can trigger the slow down in much smaller backlogs if we use a more skewed message distributor mode. Let’s move onto case 2, where we’ll diagnose the pathological fetch wait problem further. Setup: 1 topic, 12 partitions, 1 & 6 consumers, max.poll.records=500 (the default), backlog size 20M records. To make this nasty, we’ll use a partition skew using Zipfian distribution with alpha=2. This is an extreme skew where the most heavily loaded partition (p0) will receive 64% of the records (12.8M), p1 will receive 16% (3.1M) and so on until p11 receives < 1% (88K). We’ll run two tests tests: with 1 consumer (assigned all 12 partitions) with 6 consumers (each assigned 2 partitions) Coordinator output excerpt: Consumption starts strong but quickly drops to just shy of 2K records/s where it remained until the test reached the 20 minute drain timeout. Extrapolating, we can estimate a 2 hour drain time. Why just below 2000 records/s? A Prometheus query shows us the lower loaded partitions drained quickly and that the slow down in aggregate consumption correlated to an interval where p2 and p3 finished draining and p0 and p1 consumption dropped massively at the same time. Fig 3. Showing when each partition got drained, and the impact on consumption of the heavy partitions p0 and p1. Inspecting the partitions, we see that the leader of p0 is hosted by broker-1 and p1 by broker-2. So we’ve hit the following scenario (where only p0 and p1 have remaining lag): Fig 4. The topology in this one-consumer test. In a one second period (with zero ms processing time): Fetch 500 from Fetch 500 from Fetch 0 from with 500 ms fetch latency Fetch 500 from Fetch 500 from Fetch 0 from with 500 ms fetch latency In one second, the consumer can do two rounds of fetching from p0 and p1 (500 at a time), though in reality there is some overhead, which matches the 1990 records/s. This test did no better. In fact, the slow down happened far earlier. Fig 5. The drain-rate of the one-consumer and six-consumer tests. The six-consumer test hit the slowdown far earlier. We can see, while the test runs, that partitions p0-p4 still have lag (proportional to the skew). Inspecting the partition placements and share group assignments, we that these 5 partitions with lag are spread across 5 consumers. Each of these 5 consumers is assigned one heavy partition (still with lag) and one lighter partition (no drained). Fig 6. The toplogy and fetch waits in the six-consumer test. With zero processing time in this test, in a one second period, each of the 5 degraded consumers would: Fetch 500 records from the heavy partition Block on the light partition for 500ms Fetch 500 records from the heavy partition Block on the light partition for 500ms. The slow down happened earlier as each consumer fetched only from one partition per broker, whereas the single consumer fetched from 4 partitions per broker, so took longer to completely drain entire brokers. You might be thinking, how often do we need to drain a backlog, all the while the producer rate is 0? Let’s move onto case 3. Setup: 1 topic, 6 partitions, 1 consumer, max.poll.records=500 (the default), 6 brokers. One such case of draining backlogs without producer load is that of workloads where producers periodically dump a large batch of records in a topic. In between each dump there are no incoming records at all. We can model this with Dimster using its workfield. Fig 7. Per-partition rates + aggregate lag. Note that due to the rate calculated over a 1 minute interval, the short peak of 10,000 records/s is not shown. The consumer can’t handle the batch instantly, it needs time to process it. The consumption rate of the heaviest partition tops out at 1.5K records/s, building lag on that partition. In each of the three producer dumps, once the producer rate dropped to 0 and the 5 lightest partitions got drained, the heavy partition consumption rate crashed due to the fetch wait issue. Each consecutive production-batch increased the lag on the heaviest partition. In this test I used 6 brokers, to ensure that each partition was on a separate broker, to exacerbate the problem. Obvious, this test doesn’t need 6 brokers, but in production you might run 6 brokers or 12 brokers or more. In such clusters, it would be the norm for the leader replicas of a topic to be not be co-located on the same brokers. So far we’ve focus on backlog draining without producer load. But if producers keep going during the drain then the fetch wait issue can be mitigated. The size of the mitigation depends on the magnitude of producer rate. If a record arrives at a light partition twice per second, then the fetch wait issue may not be mitigated at all.  The following chart shows a small backlog from one cycle of a batch-production workload. After the peak of 12,000/s, the producer rate drops to 0 for three minutes, then every two minutes increases until it finally reaches 900 records per second across all partitions (with 64% going to p0). Fig 8. Demonstrating how the producer rate can affect the consumer rate. We can see that as the producer rate increases, the drain rate of the single partition backlog accelerates. The producer rate accelerates the consumer rate. This tells us that a continued producer rate may or may not mitigate the fetch wait issue. The lower rate, the less effective the mitigation. If we reduce to 1, plus we have fewer consumers than partitions, plus we have serious skew, we encounter a double-whammy. Round-robin fetching that returns only a single record cannot prioritize the heavier partitions, in fact, the heavier partitions are penalized as the lighter partitions cause the consumer to spend most time fetching from them. In the worst case, the consumer spends 500 ms waiting for a fetch to a lighter partition, but comes up empty, while the heavier partition is filling up. One such case, designed to maximize this pathology is: 6 records per second 12 partitions max.poll.records=1 average processing time is 10 ms 4 different load skews (via workload field): : Almost perfect uniform distribution : Light skew. . High skew : With one producer -> high temporal skew, low aggregate skew. Why high temporal skew for ? Basically, the single producer chooses a partition and sends records to it for a while, then switches to another partition for a while and so on. Within a short period of time, only one partition is receiving records. You can see the partition skew of these four tests below: Fig 9. The partition skews of each test (PINNED_PARTITIONS, KEY_ROUND_ROBIN, PARTITION_ZIPF, NO_KEY). The results show that the Zipfian 1.5 test reached only 2 records/s with one consumer and 5 records/s with six consumers. The test also saw elevated end-to-end latency, though it did not continue to grow. Fig 10. Consumer rate and p99 e2e latency over time, of the four tests. Primary mitigation : If you want to use , then the best mitigation is to use the partition count as the floor for consumer count . This completely side-steps the fetch wait problem and allows you to use without any risk of these weird performance issues under various types of skew. Secondary mitigations (less effective or with drawbacks): Consider reducing , if you have regular backlogs with no producer load (cases 1-3). The downside is if you get too aggressive, gone is long polling, instead you might hammer the Kafka brokers with a high frequency of fetch requests. Consider increasing if you experience case 5, as it allows the consumer to make up for the long periods between fetches to the heaviest partitions. Consider fixing your skew. However, even if your partitions are relatively balanced, if you accrue a very large backlog, then the lag can be skewed towards the end of the drain period.  The following chart shows drain times for a high skew backlog with different with 6 consumers and no producer load: How might we mitigate these pathological-fetch-waits with a change in how the Apache Kafka clients work? Have clients not wait the full timeout period if the last fetch to that broker returned empty. This would help in backlog drain scenarios without producer load, but not low producer load (where fetches are non-empty but high latency). No round-robin fetch requests. Have the client send concurrent fetches to all brokers of the assigned partitions. However, this weakens one of the main objectives on , which is to place a hard cap on the number of records inflight for a given consumer, in order to avoid reaching record timeouts and redelivery. Have an additional communication channel between brokers and clients, so brokers can share lag information with clients (so clients can preferentially fetch from higher lag partitions). I am sure this particular wrinkle with share groups will get worked out. In the mean time, the most sensible mitigation is to use the partition count as your floor for consumer count when using . fewer consumers than partitions and various cases of “partition skew” Consumer sends a fetch to (which hosts partitions 0, 3, 6, 9) and gets back some records for partition 0. Poll is called again, triggering a fetch to (which hosts partitions 1, 4, 7), but there are no records. Poll is called again, triggering a fetch to (which hosts partitions 3, 5, 8) but there are no records. Poll is called again, triggering a fetch to , returning more records of partition 0. with 1 consumer (assigned all 12 partitions) with 6 consumers (each assigned 2 partitions) Fetch 500 from Fetch 500 from Fetch 0 from with 500 ms fetch latency Fetch 500 from Fetch 500 from Fetch 0 from with 500 ms fetch latency Fetch 500 records from the heavy partition Block on the light partition for 500ms Fetch 500 records from the heavy partition Block on the light partition for 500ms. 6 records per second 12 partitions max.poll.records=1 average processing time is 10 ms 4 different load skews (via workload field): : Almost perfect uniform distribution : Light skew. . High skew : With one producer -> high temporal skew, low aggregate skew. Consider reducing , if you have regular backlogs with no producer load (cases 1-3). The downside is if you get too aggressive, gone is long polling, instead you might hammer the Kafka brokers with a high frequency of fetch requests. Consider increasing if you experience case 5, as it allows the consumer to make up for the long periods between fetches to the heaviest partitions. Consider fixing your skew. However, even if your partitions are relatively balanced, if you accrue a very large backlog, then the lag can be skewed towards the end of the drain period.  Have clients not wait the full timeout period if the last fetch to that broker returned empty. This would help in backlog drain scenarios without producer load, but not low producer load (where fetches are non-empty but high latency). No round-robin fetch requests. Have the client send concurrent fetches to all brokers of the assigned partitions. However, this weakens one of the main objectives on , which is to place a hard cap on the number of records inflight for a given consumer, in order to avoid reaching record timeouts and redelivery. Have an additional communication channel between brokers and clients, so brokers can share lag information with clients (so clients can preferentially fetch from higher lag partitions).

0 views

Enabling Packet Spraying over Commodity RNICs with In-Network Support

Enabling Packet Spraying over Commodity RNICs with In-Network Support Xiangzhou Liu, Wenxue Li, Zihao Wang, and Kai Chen EUROSYS'26 This paper proposes changes to top-of-rack (ToR) switch hardware to enable packets from a single flow to utilize many network paths ( Falcon offers a similar benefit via changes to the NIC rather than the switch). The Falcon approach is robust but more invasive. The switch-based approach from this paper is a more incremental change. The sweet spot for packet spraying is a data center that has a large number of network paths compared to network flows (i.e., connections). In such an environment, there is an incentive to spray the packets associated with one flow across multiple paths. The trouble with packet spraying is that packets will commonly arrive out-of-order. The system has to be able to distinguish the out-of-order case from genuine packet loss. Section 2.2. of the paper describes three techniques for handling packet loss: PFC - the trouble is that this has scalability limits, and only addresses packet loss due to buffer overflows (not angels flying down and flipping your bits) Timeouts - the trouble is that practical timeout values have to be large Selective Repeat Selective repeat is a feature of modern RDMA NICs which is similar to the bitmaps tracked by Falcon hardware. The idea is that a receiving NIC tracks an expected sequence number (ePSN) for each flow. If a packet arrives with a sequence number greater than (but not too much greater than) the ePSN, the NIC accepts it and records this fact in a per-flow bitmap. The receiving NIC then sends a NACK to the sender, requesting that the sender resend the packet corresponding to the ePSN. In the out-of-order case, this NACK is unnecessary as the expected packet will arrive soon enough. The question asked by this paper is: can one easily modify switch hardware to filter the unnecessary NACKs? The core idea proposed by this paper is that the NICs and switches agree on the number of paths for a particular flow. Sending NICs use a packet’s sequence number to determine which path to use (e.g., . The switch can then track information for each path associated with each flow. When a NACK arrives at a switch, the switch can drop the NACK as necessary (thus avoiding unnecessary retransmissions). For example, say there is a single flow mapped to 4 paths. Packets with PSNs [0, 4, 8, 12, …] will travel over path 0. Packets with PSNs [1, 5, 9, 13, …] will travel over path 1. If packets arrive at the receiving NIC in this order: [0, 1, 5 , 4] the NIC will send a NACK when it receives packet 5. However, the switch will drop that NACK because it “knows” that no packet has been received out of order with respect to its flow. Fig. 12 has simulated performance results for collective operations common in AI workloads. is the work described in this paper. CCT is a measure of how long the collective operation took. Source: https://dl.acm.org/doi/10.1145/3767295.3803588 Dangling Pointers This feels like an engineering solution to a business problem of how to get many NIC vendors to align on a packet spraying solution. I suspect there are many applications where it would not be too difficult to introduce multiple flows. For example, in a machine learning workload, the weights/deltas associated with layer could be assigned to flow . This would increase network path utilization without any hardware changes. Thanks for reading Dangling Pointers! Subscribe for free to receive new posts. PFC - the trouble is that this has scalability limits, and only addresses packet loss due to buffer overflows (not angels flying down and flipping your bits) Timeouts - the trouble is that practical timeout values have to be large Selective Repeat

0 views
Martin Alderson 3 weeks ago

Expert-aware quantisation: near-Q4 quality at near-Q2 size?

While researching and writing my last article on the history of KV cache compression , it occurred to me while there has been so much implemented research on KV cache efficiency, actual model weights quantisation is still pretty blunt. This makes sense - at large scale with many tens of thousands of GPUs the weights themselves aren't a huge efficiency bottleneck for the most part, and KV cache starts dominating memory usage. But, for us lowly serfs who don't have access to a warehouse full of HBM memory, it is a problem. The key constraint for local models is (mostly) just loading the weights into something fast enough. I spend a lot of time profiling applications to improve their performance, and a couple of months ago I built a tool to do the same for MoE models. This got me thinking. What if instead of just quantising the entire model to a certain level - the blunt hammer I mentioned - we instead profile the model first and then quantise the "cold" experts selectively , for that specific set of tasks? For this research I profiled Qwen3.6 35B-A3B on C++ programming tasks. There's an important nuance worth flagging up front: this particular model is very well load-balanced, so when it's reading code it spreads the work across its experts almost evenly (a per-layer Gini coefficient near 0 - basically uniform). The selectivity only shows up when it's generating code. And there it concentrates hard. Running a handful of C++ generation prompts through, the per-layer Gini coefficient jumps to 0.61 - meaning the top 32 of the 256 experts handle ~50% of the routing during code generation, versus the 12.5% you'd expect at random. That concentration is exactly what we can exploit: if only a subset of experts really matter for the task, we can keep those at high precision and crush the rest. Once we've got these traces showing which experts are hot (used a lot for the specific domain) vs cold (not used), we can then move on to the next step. This took (Claude Code) a fair while - ironically I suspect Fable would have been perfect for this kind of task. The core idea was to allow llama.cpp to read different levels of quantisation per expert, which had a fair few issues. Eventually though, it figured it out (running autonomously for a good 90 minutes!). It also wrote a script to take the profiling data and do quantisation per expert. All numbers below are perplexity (lower is better) measured on CPU. "Reading code" is a held-out chunk of real C++ source; "writing code" is a set of the model's own C++ generations. The tiered models keep a "hot" set of 64 experts (out of 256) at high precision and drop the other 192 "cold" experts to 2-bit. * The "writing code" eval was generated by the Q8 model, so scoring Q8 against it would be circular - it's left out. A few things jump out. First, the baseline. Full-fat Q8 (35 GB) scores 1.568 reading C++, and a "blunt" Q2 quantisation of everything (13 GB) jumps to 2.103 - a big drop in quality for less than half the size. (Perplexity is roughly "how surprised the model is at each token" - so going from 1.57 to 2.10 is the model getting noticeably dumber, not lobotomised, but clearly worse.) Now the actual experiment. I A/B tested the tiered approach two ways: random - pick the hot experts arbitrarily, as a control - versus profiled - keep the experts our profiling flagged as hot for C++ and crush the cold ones. The profiled version wins every single time: across two precision tiers and both eval sets, that's four out of four. With Q8 hot / Q2 cold (18 GB), random tiering scores 1.667 while the profiled version recovers nearly half of that gap back towards Q8, landing at 1.620. So the core idea works - which experts you protect matters, and the profile tells you which ones. But here's the catch I have to be honest about: uniform Q4 is really good. On code, 4-bit is almost lossless - Q4 (20 GB) scores 1.582, basically tying Q8. So the fancy Q8-hot/Q2-cold model, despite all the cleverness, doesn't actually beat just using Q4 everywhere at a similar size. The win shows up when you go smaller than Q4. I built a Q4-hot / Q2-cold version - 4-bit for the hot experts, 2-bit for the cold ones - which comes in at 14 GB, just 1 GB more than the blunt Q2 model. And it scores 1.635 reading and 1.477 writing - recovering ~90% of the quality gap between Q2 and Q4 for that single extra gigabyte. That's the real result: near-Q4 quality at near-Q2 size , by spending your bits on the experts that actually matter for the task. This is absolutely nowhere near production ready and needs a lot of work from someone that knows the llama.cpp codebase far better than me. I only ran this on CPU which is (very) slow, my eval sets are small, and there's no doubt the vibe coded implementation Claude came up with could be improved further. There's loads of interesting angles to continue researching on this. I tried a couple of tiers here (Q8/Q2 and Q4/Q2), but there's no reason you couldn't go further - pushing the cold experts down to sub-2-bit (IQ1/IQ2) would drop the model below the uniform-Q2 size while keeping the hot experts sharp. You could imagine a whole gradient: hottest experts at high precision, then incrementally more aggressive quantisation as experts get colder. There's also the question of how many experts you keep sharp. I protected 64 of the 256 here, which turns out to be pretty generous - generous enough that even picking those 64 at random recovers around 80% of the Q2-to-Q4 gap by itself. That's less surprising than it first looks: an MoE layer's output is a weighted sum of its active experts, so keeping any quarter of them accurate anchors the result no matter which quarter you pick. Profiling buys the last ~10% by making sure the experts that actually fire are the protected ones. Where I'd expect it to really pull away is at small hot sets - keep only 16 experts sharp and random selection would mostly be protecting cold experts and fall apart, while the profile tells you exactly which handful matter. That's the experiment I'd run next: it should shrink the model and widen the profiled-vs-random gap, which is where this whole approach earns its keep. But in the end I think this is a really interesting approach. If we could get mass-scale profiling data from real world llama.cpp executions, it may allow a really big jump in quality. I can see a world where the harness detects what domain the task is in, downloads a quantised model for that specific domain and then runs prompts through it. This really takes advantage that storage is cheap (relatively speaking) and RAM is expensive . So having a bunch of different quantisation variants - of the same model - on disk is pretty doable. I should add that there is a fair bit of prior art in this space. The closest I found, DynaExq, does something very similar dynamically at serving time from router traces - but I couldn't find anyone doing it as a static, domain-profiled quantisation you ship as a single GGUF. Here are some links that I read up on while doing this: Closest prior art (variable precision per expert): Foundational quantisation: On-device routing in the wild: If you're working on these kind of optimisations I'd love to hear from you - please feel free to reach out on my contact page . DynaExq - a serving system with a "hotness-aware precision controller" that reads router traces to keep hot experts at higher precision and crush the cold ones, done dynamically at runtime. Mixture-Compressor - folds expert activation frequency directly into a per-expert bit-width allocation. MoPEQ - assigns per-expert bits by sensitivity and explicitly avoids using activation frequency to do it - a nice counterpoint to the approach here. AWQ , GPTQ , SmoothQuant and SpQR - the lineage of protecting the salient weights and crushing the rest. SpQR in particular is basically the within-tensor version of what I'm doing across experts. llama.cpp's importance matrix (imatrix) and k-quants - already uses calibration-data importance to steer per-weight quantisation. This is really just pushing that same idea up to per-expert granularity. Apple's on-device and server foundation models - their ~3B on-device model uses a mixed 2-bit/4-bit scheme (~3.7 bits per weight) with LoRA adapters to claw back quality, then routes harder requests to the cloud. Not far off the "small quantised model for the task at hand" idea.

0 views
Giles's blog 1 months ago

10Gb/s Ethernet: switching to a Broadcom SFP+ module

Back in April , I upgraded my home LAN to 10Gb/s. The in-wall cabling is CAT-6 or similar, so I had to use 10GBASE-T. Now, the router I'm using, and the switch in my study, provide 10Gb/s through SFP+ cages; that meant that they needed 10GBASE-T SFP+ modules in order to connect. That kind of module is known to run hot -- sometimes too hot to actually work. The modules in , the router, appeared to be running OK (see the linked post above for charts), but the one in , the study switch, was a worrying 93C. I tried sticking some mini-heatsinks on it , which seemed to help a bit. But the weather got warmer, and eventually the module overheated. I lost access to the Internet from the study, and checking the metrics showed me this: You can see that it's "flapping": the temperature gets up to a level where the module shuts itself down for its own protection -- about 95C, I think -- and then when it has recovered, it switches on again, the temperature rises, and the process repeats. I was able to work around the problem by switching on the air conditioning in the study. But normally I only have it on when I'm in there, and keeping aircon on 24/7 just to keep the network working felt like the wrong solution. It was time to switch to a more power-efficient SFP+ module. My original 10Gb/s post had quite a lot of discussion on Hacker News , and mentioned that there are two generations of 10GBASE-T SFP+ modules: old ones using a Marvell chip, and newer ones using one from Broadcom. on the ServeTheHome forums made the same point. The Marvell-based ones were known to run hot, and they both recommended finding Broadcom-based ones. I'd confirmed that the MikroTik S+RJ10 that I had in was indeed a Marvell one, so the solution was pretty simple: get a better one. So I went on Amazon and picked up a 10Gtek ASF-10G-T80-INT . Checking 10Gtek's own page on that module confirmed that it used the right kind of chip (although it was a little bit garbled): 10Gtek's ASF-10G-T80 is a newest version copper transceiver, its biggest feature are ultra lowpower consumption and longer transmission distance (1.6W C10Gbps 30m,2.0W 110Gbps 80m). ASF-10G-T80 is a 10GBase mult-rate Copper RJ45 SFP+ transceiver, designed in with BROADCOM BCM84891 PHY chip following IEEE 802.3an/az and SFP+ MSA, supporting up to 80-meter transmission over CAT.6a or CAT.7. A day or two later, it arrived. It came in a rather pretty little metal case: Installing it took a little while, because I found removing the existing MikroTik module tricky; Willie Howe's video on YouTube helped quite a lot in showing how to disengage the latch, but I still needed to fiddle around with it quite a bit to get it out. However, that was eventually done, and the new module went in. I plugged all of the network cables back in, switched on the switch, and (after a slightly nerve-wracking wait for it to boot up) the network was back up and running! So, were the temperatures any better? I checked my monitoring, and: Huh, nothing was being reported. That made sense, though. The way I was charting those numbers was that the switch exposed them over SNMP, and then the Telegraf daemon on my router, , read the numbers and sent them to InfluxDB ; finally, Grafana did the charting. I'd been reading the module temperatures in using the SNMP OID that I'd identified that the switch was providing them on ( if you're interested), but perhaps the new module was published on a different OID. It was time to log in to the switch and take a look. It's saying that it's an Intel module; that in itself is not all that odd -- there are frequently compatibility issues between switches and SFP+ modules, so sometimes modules are configured to "lie" about which manufacturer made them -- and I'd specifically bought the "Intel-compatible" one on Amazon, the , because I couldn't find one that pretended to be MikroTik. Research had suggested that it would work OK, and it did. But the really odd bits were these: Not only was it impersonating an Intel module -- it was saying that it was a fibre-optic one ! Perhaps if I had found the "MikroTik-compatible" option it would have been better -- though, equally, it might have just impersonated a MikroTik fibre module anyway. Anyway, it was working -- so that was OK. But there was some bad news. If the switch was able to read a temperature from the new module, then you'd expect it to appear in that output, as . So, sadly, I don't think I'll be able to monitor the temperature of the new module. How could I tell whether it had helped, then? Well, one thing would be to simply see if there are any further instances of network flapping. I actually did the replacement just over two weeks ago, and everything has been fine as far as I can tell from using it and from the other monitoring (despite another hot week last week). But another interesting metric is the CPU temperature for over the two weeks before and after the module change: You can see that there was a real drop-off late on 1 June, when I switched the modules, and it has been running about 5C cooler since. Of course, there's a lot that's different about the new module -- as well as having a different chipset and a mendacious EEPROM, it's likely to have different thermal coupling characteristics -- it might be shedding more or less of its heat to the SFP+ cage and thence to the switch's CPU. So it's not proof of anything, but in combination with the improved link stability, I'll take it as a win. So, an interesting little excursion into the world of SFP+ modules -- in particular, slightly dodgy ones :-) Let's see if this one holds up better as we go through the toasty Lisbon summer.

0 views
Dangling Pointers 1 months ago

LightDSA: Enabling Efficient DSA Through Hardware-Aware Transparent Optimization

LightDSA: Enabling Efficient DSA Through Hardware-Aware Transparent Optimization Yuansen Wang, Teng Ma, Yuanhui Luo, Dongbiao He, Zheng Liu, and Yunpeng Chai EUROSYS'26 This paper describes performance characteristics of the Intel DSA hardware accelerator, and software techniques to maximize performance when using DSA. My takeaway is: the DSA supports a variety of convenience features, but each one is so expensive that you are better off adding software complexity to avoid these paths. The Data Streaming Accelerator ( DSA ) is a hardware accelerator in recent Intel chips. It can implement simple memory operations like , , , and CRC generation. Fig. 2 contains a high-level diagram of the DSA architecture. Source: https://dl.acm.org/doi/10.1145/3767295.3769356 Operations are written into work queues as 64-byte descriptors. A work descriptor (WD) describes a single operation, whereas a batch descriptor (BD) references many work descriptors. A batch is the fundamental unit of control (the DSA signals the CPU when a batch has completed). The DSA contains multiple engines and arbiters to spread work across the engines. Source and destination buffers accessed by the DSA do not need to be pinned into memory, the DSA can handle page faults. The DSA supports demand faulting via the page request service (PRS). This enables the DSA to send an interrupt to the OS (via the IOMMU) requesting the OS to resolve the fault. This paper reports a similar finding to a previous paper looking at the PCIe page request interface: demand faulting is convenient, but slow. The LightDSA authors recommend that software forcibly fault in pages before submitting descriptors. The DSA supports operations that require unaligned reads and writes of data from/to DRAM but the authors find that 64-byte aligned accesses are much faster. Fig. 8 has some numbers, even 32-byte alignment is expensive (compare the light green and dark green bars). Source: https://dl.acm.org/doi/10.1145/3767295.3769356 The authors recommend having software ensure that all writes performed by the DSA are 64-byte aligned. Software can do this by executing the operation for the first few bytes of each task, up until the destination buffer is 64-byte aligned. Like many HW/SW interfaces, the DSA writes both result data and metadata to memory. Result data is associated with each work descriptor, while completion metadata is associated with each batch descriptor. Metadata is read by software to learn when an operation completes. In such a scheme, it is important that software observes the batch metadata write after the result writes have completed. If the metadata write can land first, then software may try to read the result buffer before it has actually been updated. The DSA supports multiple traffic classes (TC). As with discrete PCIe accelerators, writes from the DSA associated with the same traffic class will land in host memory in order (these are posted writes ). However, writes associated with different TCs may be reordered. Here is a previous paper that describes performance problems with reordering. Section 3.8 of the DSA architecture specification describes two choices that software developers have. Either they should configure work descriptors and batch descriptors to use the same traffic class, or they should configure the DSA to enforce ordering via the (readback) flag. When that flag is set, the DSA will ensure that all result writes have landed in host memory by issuing a read request to read the most recently written result data back to the DSA, waiting for the response to come back, and then issuing metadata writes associated with the batch descriptor. Discrete PCIe devices can use the same trick to enforce ordering across traffic classes. Fig. 7 shows the performance cost of using this feature: Source: https://dl.acm.org/doi/10.1145/3767295.3769356 My takeaway is that DSA users should ensure that work and batch descriptors use the same traffic class, to avoid having to invoke this slow read-back path. Because the DSA contains multiple engines, tasks can complete in a different order than the order in which they are submitted. This is fine in itself, but the authors note that software must take care to efficiently support allocating work and batch descriptors in light of this. Time spent bookkeeping to handle out-of-order completion is overhead that adds up for small tasks. The solution proposed by this paper is for software to maintain two batch descriptor lists (free, and busy). When software needs to recycle descriptors from the busy list to the free list, it checks most (but not all) batch descriptors in the busy list to see if the hardware has completed the batch. This is in contrast to an approach which simply checks to see if the oldest-submitted batch has completed. The paper finds that it is optimal for the recycling process to ignore the 25 most recently submitted batch descriptors but check the completion status of all other outstanding batches. Figs. 12 and 13 compare the performance you can expect to see from using DSA naively versus using the techniques described in this paper (LightDSA). My takeaway is that the DSA is powerful, but only if you use it carefully. Source: https://dl.acm.org/doi/10.1145/3767295.3769356 Dangling Pointers I suspect the elevator pitch for DSA is something like: “just re-compile your existing C/C++ code and all of the memcpy/memcmp time will be optimized out”. It seems like DSA falls short of that. I wonder if the elevator pitch would be better realized if application code was written in other languages (like an explicitly pipeline parallel language). Thanks for reading Dangling Pointers! Subscribe for free to receive new posts.

0 views
neilzone 1 months ago

Speeding up static site generation with BSSG

Three months ago, I moved from hugo to BSSG for this blog (and my work blog). You can get BSSG here . I’ve been really happy with BSSG, and a couple of recent changes by Stefano have made it even better. I have a minimalist blog. A list of posts on the front page, and generally text-only posts. I like it to load fast even though it is running on a Raspberry Pi 4, along with a couple of other bits. This means that there are some features of BSSG that I do not use, including descriptions of blogposts. I use the title for that, on the basis that this should be informative in itself. It suits me, anyway. There are also some other UI elements that I do not need, such as reading time. I bodged my way around these, using CSS rules to hide the unwanted content from display. I could have changed the code to neither generate nor display them, but I didn’t really want to run, and need to maintain, my own branch. With the recent changes, Stefano added some new config options: These are set to “true” by default - to preserve the experience for people who already use BSSG and expect these things, which makes sense to me - but now I can set them to “false”, and have an even slicker, faster experience. The second brilliant change is about the way the scripts handle incremental updates. The idea being that, rather than building every post, every time, it will just build the new posts. I struggled to get this to work initially, as it was building all posts, every time. This turned out to be entirely down to me: my build script, which I use to control building and deploying both the cleartext and .onion versions of the blogs, cleared the output directory each time. I removed that, and bingo, incremental updates! This combination of things meant that building each site went from ~10 minutes (which was a bit painful) to ~1 minute (which is fine!). Happy days.

0 views
Giles's blog 1 months ago

JAX: commitment issues

Imagine you have JAX code like this, and run it on a machine with CUDA set up: We're creating a big array, blocking until it's ready (JAX is asynchronous, so this makes sure that it's actually finished creating it), then getting the first item, and as a belt-and-braces thing making sure that that is ready too. How long do you think those last two lines -- a simple retrieval of a 6 x 1024 array from a larger one -- will take? Some tiny fraction of a second would seem reasonable. But running it on my machine just now, the answer is a bit of a surprise: just over 5 seconds. And if you try to get immediately afterwards, it still takes about 1.2s. Further lookups into consistently take more than a second -- so while the larger initial number might be something to do with setup -- maybe internal stuff being JITted -- that's clearly not the whole story. Something is making these seemingly-simple array lookups take much longer than you'd expect them to. Let's dig into that. First things first, why would you want to do that slightly strange dance with the context manager in the first place, rather than telling what device you want to use (eg. with )? I'm writing some LLM training code, and want to load my training dataset. I don't want to load it into the VRAM on the GPU -- that would be a waste of valuable GPU resources -- so I need it in the CPU-side memory. I'm using Safetensors, which will load stuff onto the system's default device . So I need to override that temporarily to make sure that the dataset is loaded onto the device where I want it. I initially discovered this problem when I tried to iterate over the resulting array in my training loop; the code above is a simplified version of that -- a minimal repro of the issue. And it's a serious one! If each iteration has an overhead of 1.2s just to get 6,144 tokens ready for the model, JAX will max out at about 5,000 tokens per second of training speed just due to that overhead -- a real forward and backward pass plus an optimiser step will obviously make things even slower. For comparison, my PyTorch training loop managed almost 20,000 tokens/second on the same hardware: all steps from getting the training data, putting it on the GPU, and doing the actual training. So, let's look at that code again. We've created our variable on the CPU explicitly, and indeed if you print , it says . But if you print the device of the , you get . What's worse, if you watch while the code is running, as soon as it hits the lookup into the array, it starts using the GPU -- for each one, there's a spike in GPU usage. So, what gives? We asked JAX to put the array on the CPU, but now it's doing GPU work, and putting the items there. The problem is that when you create an array using the context manager, it is placed on the specified device, but it's not committed to it. If an array is not committed to its device, then JAX will feel free to move it around to others. In order to commit an array to a device, you need to use explicitly stating which device you want it on. Running the same code, but with this: ...immediately before the lookup into the array changes the numbers drastically; the first lookup takes about 0.95s on my machine, the second 0.0002s, and then subsequent ones less than 0.0001s. I decided to exercise this in depth, and wrote this script . If you run it without the command line flag, it will create the array, then iterate over the first ten items, measuring how long it takes to get each one. Running it just now: With the flag, it uses to explicitly commit the array to the CPU. Running that: Now, that didn't quite cover my use case -- what if, I wondered, the slow operation was putting things onto the GPU? The script also has a flag to do that -- after getting each item, it uses . With that flag: So, there's still a small startup penalty -- perhaps JAX is having to JIT some of its internal stuff -- but a perfectly decent speed after that. Commitment works! I'm still building my mental model of how JAX works, and working out exactly what is going on here is proving a bit tricky. The split between a committed and an uncommitted array seems clear; the former is tied to a device, while JAX will move the latter around as needed. It also makes a certain amount of sense that it would want to move the items to the GPU; it is, after all, the default device. But I'm less clear on why that was so slow, compared to the manual process of getting the item then putting it there. Hypothesis: the array is on the CPU's RAM, but not committed there. We ask for an item from that array, and maybe JAX wants that to be on the default device, the GPU. So it moves the entire "parent" array there, extracts that item, and then returns that. Then next time around when we ask for the next item, it does the same thing again. Plausible? Maybe, but it does sound a bit pathological! Anyway, at the end of the day, I have a solid new heuristic of my own: if you want something to definitely be on some specific device, make sure that you nail it down there with . And then you won't have commitment issues like these. Getting the zeroth item from the array took about 5.4s. Each subsequent one consistently took about 1.2s Getting the zeroth item from the array took about 0.95s Each subsequent one took less than 0.0002s. Getting the zeroth item from the array took about 0.86s, and putting it on the GPU took 0.02s. Subsequent items had "get" times similar to the previous run, and "put" times of about 0.0006s.

0 views