Spiral Logo

Latest posts

From S3 to GPU in One Copy

Sep 7, 2026 · by Onur Satici · 25 min read

This talk, From S3 to GPU in One Copy: Rethinking Data Loading for ML Training, was originally presented at QCon London 2026 and published by InfoQ, with the video recording and transcript, at infoq.com.
Before I get into anything, I want to show you this video. Not specifically like what's playing in the video, but how we produce this video. Because this is not backed by a regular MP4 file in some main disk, but this is actually a visualization of a file scan. What I mean by this is we have a Vortex file, Vortex being a columnar file format. It has three columns, one per color. Each column would then have a row per frame of this video. Each frame would include around 8 million pixels, because it's a 4K video. Then we then process this data, which is three columns, around 8 million pixels per frame, 60 frames per second, so around 13 gigabits per second, from S3, through the network card, over the CPU, and to the GPU. The GPU is seeing this data this fast, but we want to see what's happening in the GPU, we want to visualize that.
We can't downlink that much data to our laptops, so because it's already in the GPU, I'm using the GPU itself to re-encode it into H.264, wrap it into TCP buffers, send it over my SSH tunnel, and we can display and see what's happening. Now have a look at this. This is the same video, but this doesn't have any post-processing on it. This is actually a visualization of a column pruning, or projection pruning, where we only select, in our projection expression, one column, and only the bytes that are associated with that column are flowing through the same pipeline, keeping the same bandwidth, and then ending up with the video that you were seeing before.
Today I want to talk about how this is possible, and the technologies that enable us to do this. I'm Onur. I work at SpiralDB. I help maintain Vortex, which is an open-source file format that is under the Linux Foundation. I want to talk about what makes Vortex special. I'll start with why we need this speed in the first place. I'll then move on to Vortex itself, explain some of the main topics around Vortex, layouts and arrays and the scan itself, and then end with what is possible.

Problems in Data Loading to GPUs

To start with the problem, why we need the speed. Because you have some GPUs that you hired and you want to use them. GPUs are expensive, and then each time they're idling, it's expensive, and it's an opportunity cost of not doing valuable work. I want to tell this like we have two types of taxes that we pay when utilizing GPUs. The first one is a movement tax. Getting the data from some source that you have into the GPU is not trivial, and it's not really cheap. If you use web dataset for training, or for example, MosaicML's streaming dataset, then they go more or less these steps. For example, let's say your data is in S3. You fetch from S3 into the disk, into NVMe. From NVMe, you read compressed bytes into your RAM. You decompress on the CPU, and then send those decompressed bytes over the host of device link, PCIe, to the GPU.
This not only has many steps that you have to follow through. If you zoom in into the supported bandwidth per connection, you can see that we are bottlenecked mainly by CPU decompression, and the NVMe throughput. The reason why they all persist into NVMe is the main consumer of this pipeline is PyTorch. PyTorch is Python. Python doesn't like multi-threading, so therefore we use multi-processing. Each worker doesn't want to step into each other's toes, so they want to deduplicate the downloads, so they persist the shards on the disk. Ideally, we want this pipeline to look more like that, where you fetch from S3, eliminating the bandwidth bottlenecks through the network card speeds and the PCIe speeds to the GPU. Then, further, you want to use the right compression that enables you to decompress in the GPU, the parallel compressions, and GPU can decompress much faster than CPUs. You're not only eliminating the bottlenecks you had in NVMe, and you're streaming much faster now, you're streaming maybe 50 gigabits per second, but those bytes are compressed. If you pick the right compression, effectively, this pipeline runs through 10 times faster than the actual compressed speed. That was movement.
Sketch of two data loading pipelines. Top: S3 to NVMe to RAM to CPU decompression to PCIe to GPU, with NVMe and CPU circled as bottlenecks. Bottom: S3 to network card to GPU, which decompresses in parallel.
There's another tax I want to talk about, which is decision. It is when you want to iterate on your training workload. You've loaded from your source into the GPU, and maybe you did some preprocessing to your training, then you want to evaluate the results. What you want to then try, for example, is a new data mix, or you want to change the curriculum. You want some new filter on this data. With the current tools we have, you have to reprocess the entire data, probably re-tokenize, reshuffle to get the right order, and then load again to your source, which might be S3, might be somewhere else, but this is slow. If you look at the hypothetical pie chart of your percentage of time spent, you want to maximize the gray, where you're thinking, and the green, where your GPU's working. Everything else is overhead. You want to turn this into that, where you load from your source into the GPU without wasting much time. Then once you evaluate and decide you want to change something in your input source, then you just change your scan. You just give a different query, a different filter. You don't need to reprocess. Then you immediately can start training for your next iteration.

Vortex Overview

How does Vortex, using a different file format, enable all this? Vortex, to give a quick overview of what it is, it's a columnar file format. It is similar to Parquet, but it's also much different than Parquet. It has an extensible spec, a minimal spec that you can plug in into various points. It has a separation of logical types versus physical types. That's one of the main differences from Parquet, is that Vortex doesn't have a notion of tying the types themselves, like integers, into how they're encoded on disk. It's up to you, it's decoupled. It does as little as possible, as late as possible, saving a lot of memcopies for materializing intermediate results, and leveraging from query optimizations. Then it can push through most of the projections, filters, and aggregation from your compute engine into the file format itself. Also, the way it does compression is different.
It doesn't use block compression that loses the meaning of the underlying data. It does use lightweight and cascading encodings that can also give a similar compression ratio, but more importantly, they allow you to do compute on the compressed data. You can do random access, you can do aggregations. Some of these computes can be even faster than doing it on the decompressed data itself. To give some concrete numbers versus Parquet, this S3 to GPU scans that we are showing is around 30 times faster. Thanks to the encodings and the compression Vortex has, it's around 100 times or more faster than Parquet in random access.
What does the file look like? We will approach this from a file reader's perspective. There's a Postscript that's fixed size, you read it, it has a lot of pointers into other sections of the file. There's a footer. The footer itself is a legend, it's a bill of materials of what you expect to see in this file. You have a list of arrays and you have a list of layouts. Arrays are how you encode the data itself in Vortex, and layouts are like the logical plan. They are the I/O layer of pruning. We will dive into that later. These are showing you the main plug-in points. If you have a new array of a new layout, you just add it here, and you have the logic in your reader, then Vortex will delegate to your reader to do what you want to do. Thirdly, and maybe most importantly, it's the list of segments that you expect to see in this file, and the segments are the majority of the file.
They have the array data themselves. They come with triplets, so offsets, length, and alignment. They're pointers, but they also have alignment. Alignment is important to bake in the file, we think, because if you have the alignment, you know that if you want to use SIMD operations or GPU kernels when you load this, if you know the alignment beforehand, in your receiving buffer, you allocate that buffer with the right alignment, so you don't need to copy, just to dispatch the compute on top. I've talked about arrays and layouts, but what are layouts? Layouts enable you to prune those segments as quickly as possible, given a filter and projection. They are like the logical plan. They are also very minimal. This is the entire flat buffer definition. The only two maybe important points I want to touch is the last two fields. It has the children, which is an array of itself, so it's a tree, and each node in this tree has segments.
Segments is a list of integers, and those integers point to the offsets of segments. We know from the footer where they are in terms of pointer, like exact byte ranges. Each node in the layout tells, I am responsible for reading these segments, and give me a filter, and I'll tell you which of these segments I'm responsible for, you have to read.

Vortex Layouts

To give an example on how layouts work, let's start with a schema, for example. We have the root layout. It has two columns, a name, string, an age, integer. The StructLayout only tells it, I have a child per column, so you expect it to have two different children, one per column. Each of these is a ZonedLayout itself. ZonedLayout is interesting because it not only contains the data as segments, it also contains another segment that is a summary statistics of the data that you expect to read. That's also a segment itself. When you read that segment, it's a zone-map. A zone-map is a Vortex array by itself, but it does have a per row range in the actual data, and has columns of statistics, like min, max. The data is then just a concatenation of multiple segments. It's a way of breaking it down into smaller arrays when you want to read them.
How does this work, then? Say you have this query. You want to get the name column, but you only want to get the rows where the age column is greater than 30. You start from the root. Root knows it has a projection and a filter in each column, so it delegates into each ZonedLayout through this. The age will receive a filter, and it will then initially load the zone-map, check the zone-map, check the filter, find which segments in this data passes the filter. Let's say the last segment passes the filter, so then it returns the last segment. We also get the name column to return the last segment, because that's the actual data we need. Do the actual filter on the actual rows, and return the rows that we need. By reading the zone-map and only 30% of the segments, you can satisfy this query.
Maybe this is a more detailed view. The center is the zone-map. That was a segment. We read it. We deserialized it. It's an array. It has one row per row range, and we have the filter. By just looking at the max value, we can prove that only the last row here can have any rows that match this filter. All the others you don't need. You read the entire chunk. That's the Vortex atomic unit of reading. It's a segment inside. Then, what's neat about Vortex is the compression we have is sliceable. You don't need to decompress everything. You slice it first, and then you only decompress the rows that match the row range we have it. The last 8k rows is decompressed. That is like doing as little as possible and as late as possible. The difference between chunk granularity and row granularity is also, I think, important to mention, because these serve different purposes.
Chunk granularity is an I/O concern. It deals with what type of I/O source you have, what is bandwidth and latency characteristics, so you can tune them in Vortex. The zone granularity itself is a compute concern because, depending on your architecture, you might have different wider SIMD registers that you want to use, and those play well with different lengths of data that you have. You can tune them as to your liking with Vortex.

Vortex Arrays

We talked about reading the segments and layout is helping us prune to the segments that we want, but what do each segment contain? They contain a serialized, most likely compressed Vortex array. A Vortex array, I want to also explain with an example. Let's say we have this array. It's an array of arrays, so the element is a list itself. The elements inside the inner lists are strings. We want to encode them and compress them as much as possible while keeping the meaning. We can start with a ListArray that's similar to Arrow. It basically flattens the values and saves the flattened values as a values child, and then it keeps the inner brace locations because it removes them while flattening, it loses the information, so it keeps that information into the offsets child. Any two consecutive offsets would point to the inner braces of an inner list location.
You can see this is also a tree. Now we have the ListArray, but two children are also arrays themselves. We can go on further, we can compress further. For example, the values child has only three unique elements so we dictionary encode them. Dictionary encoding gets the unique values and stores them as children, so like in a values child has one city appearing once only, but you then encode the repetition of the original array as the indices that point to the values child. Then you decoupled the values themselves and the repetition into separate children. Then you can continue further because the codes now have contiguous repeated values, which are called runs. You can run length encode them, or in Vortex, more specifically, run end encode them, meaning you get the values that repeat, save them as a child, 0, 1, and 2 are the values of each run here, and then you also encode which offsets in the original array these runs end, so the actual indices that end.
With this, we have the string values as a child, but we also have multiple integer values that we have, and we can see that all of these values are really small. The values child of run end, they're all smaller than four. Four is second power of two, so you can encode each of them in two bits. The ends and the offsets are all less than eight, so you can use three bits to encode them. You bitpack all of this integer data you have and end up with the actual bits on disk like that. That means this is total 30 bits or so, so this is less than one integer. It's less than 4 bytes. Then using this information, you are storing all the repetition and nested structure of your input array, and you only store the unique elements once. We can further compress this. We have FSST, for example, is an algorithm that extracts the common substrings and then further divides into arrays.
For this example, I think this gets the point. I want to separate these two things into two ways. You have the arrays, which is a structure, which is how you encode this segment. Then, it is important because this is what you would use to dispatch compute. On the right-hand side, you have the data. I want to think of these two as the control plane and the data plane, and that will be important in the scan, which we'll go into later, but before, I want to mention one more thing. Having this array tree on hand and having this data as pointers somewhere in some location, you can dispatch compute without decompressing. For example, I want to get a random read. I want to get the second element of the first list. If you get that query into the ListArray, ListArray knows that it can binary search the offset child to find the actual location, then does propagate that to the flattened values child because it now knows the actual location.
DictArray can do the same for code, and then substitute with the right value before returning. Also, I mentioned some compute you can run on the compressed data might end up being faster than running the compute on decompressed arrays, like summing, for example. Summing requires, on a decompressed array, iterating through each element and summing the values. If you have a run end array, that's just multiplying with the lengths of each run, and then per run, you are summing the total result of the multiplication. It's a lot faster to run the compute on compressed data.

Vortex Scan/Byte Copies

Back to the separation. The control plane shows you the amount of information you need in a compute engine so you can dispatch work on the data itself. The data plane is the information you have to transfer to your compute engine. These serve different purposes, and we don't want them to step in each other's toes. How do we do this? Vortex has a scan that tries to maximize this throughout. The way it works is it tries to maximize the I/O engine's work and the compute engine's work to be busy all the time. Then, scan is the orchestrating layer. The orchestrating itself also wastes compute and has to dispatch work, reconstruct the array trees, and then follow the array tree logic all the way to the end. The I/O layer then gets some bytes to the compute source, returns the pointers, the scan then dispatch work on those pointers.
A small detail, but compute also can request more segments because, for example, in the query that we initially did, select name where age is greater than 30, you don't know if you need name column before executing the age filter. It could just be pruned all together so you don't end up getting those segments. Say we have a query in the scan, and then we have the layout in the scan so we know which segments we need to fetch. Those are transferred into byte ranges, and these are byte ranges that you want to get from your source. Each individual byte range might have a latency, and depending on what source you're reading, you don't want to dispatch each individual byte ranges by itself. We have a coalescing layer on top, and coalescing means if two byte ranges are close enough sufficiently, you merge them and then have a single byte range.
You get extra bytes in between, but because you pay the latency once and get the entire throughput, it works on your benefit. This is source-dependent. NVMe is happy with some coalescing configuration, but S3, for example, might require a lot more. S3, you can do 16-megabyte segments because it's a huge throughput with huge latency. Once you get the segment request and then have the byte ranges, you then dispatch work on them. It's a CPU scan. This work is done when you have the bytes on RAM. Then the work you dispatch is regular Rust functions like slice, projection, filter, any compute that the input query wants you to do. That was CPU. Most of the things stay the same when you are scanning to the GPU. The scan orchestration stays the same, the coalescing layer stays the same because we still need those byte ranges, but now the I/O plane has to ship these buffers all the way to the GPU and return pointers to the GPU. That's when its job is done. The compute, now instead of dispatching Rust functions, it does dispatch CUDA kernels, but everything else is the same.
We have a buffer pool in the middle, and I want to explain why we have a buffer pool in the middle. This is the lifetime of a byte range through a scan from S3 to the GPU. Initially, when you request bytes from S3, it lands on the kernel, receive buffer. This is a direct memory copy, so it's not an actual copy, it's the first place that these bytes land from the network card, is this kernel, receive buffer. It immediately copies the ciphertext, still in a TLS encrypted form, into the userland, one-to-one. Then we are in our HTTP libraries land where we use Rust TLS and request. Rust TLS decrypts it into plaintext copies, that's one copy, and then it's still TCP, we want to get the HTTP body out of it, so the request layer deframes it, gets the body into its own buffer, then finally it arrives to our own Vortex land.
Because these yellow buffers are all streaming, because they're multi-part GET request, we have to aggregate them because we want the segment, we want a contiguous byte range. We aggregate them, so we have to copy one more time, but that's the only copy we do. We receive these chunks as a streaming fashion, and then we accumulate them until the request is done. Immediately after this is done, we dispatch the CUDA. We do CUDA async copy and expect it to land on GPU, but unbeknownst to us, internally, CUDA does another copy. It does another copy because it requires some alignment rules from us. Basically, the buffers you can copy to the GPU from the host has to be pinned and page locked, meaning it should be outside the operating system's virtual page system, so then the copy, if the buffer gets paged out during the copy, GPU doesn't segfault and stays happy.
Before diving into how we can reduce the copies, I want to mention one more thing. The memcpy cost is what's important to us. The unit is a core per gigabit of bandwidth you can sustain. It's important because this core budget is also used by the orchestrating layer in scan, and also compute if you're doing the compute on the CPU. It has the opportunity cost, because you have limited CPU, if you're just wasting it on memcopies all around, then you slow down your own bandwidth. It's like a double-edged damage, so you want to reduce this as much as possible.
How can we reduce this? Initially, we start with the CUDA bounce buffer because we know the requirements. We can satisfy those requirements on our own buffer that we accumulate the HTTP buffers into. We have a pinned buffer that we use, and then we then call CUDA async copy on it. It doesn't use a bounce buffer, and this goes directly into the GPU. Further, because allocating these are expensive, we keep a buffer pool around to amortize the allocation cost. The way we reuse these buffers is when you dispatch the CUDA copy, you append the CUDA event after that CUDA stream. You append an event. Once that event fires, you know everything that comes before it is complete, so then you can safely reclaim the buffer because that means the transfer is complete, and you can reuse that same buffer for next copies in your pipeline. This reduces the cost a bit, but we need to then look to the left, to the HTTP land, to get rid of some of the copies.
One thing you can do here, if you have a sufficiently high Linux version, you can use a kTLS, kernel-side TLS decryption. That pushes the decryption into the kernel, and because the kernel was immediately copying into the userland, then it doesn't need to do it anymore because now it's decrypted, and then the decrypted can be in place. The decryption can be in place in the receive buffer itself, so it saves one copy. There's one caveat that the Linux ciphers are mostly not really that up-to-date in kTLS, so they might not end up using the widest SIMD registers your machine has, but still, the memcpy saves here because we reduce one copy, offsets that deficiency, in a way, so we still keep this kTLS in this land.
What else can we improve? We can improve more things on the HTTP side, but for this, we need to do bigger changes because there is no low-hanging things we can do here. If you do bigger changes, namely writing your own HTTP client, then it pays off well, because if you write your HTTP client, you control which buffers you give to kernel. If you do it, you can use the already pinned and page locked Vortex buffer you have to accumulate and deframe on the fly in the same buffer. Once you get the decrypted bytes as a stream in the multi-part GET request, you keep allocating them into the same buffer you already had to be ready to be sent to CUDA. Then when the multi-part GET request is complete, you dispatch to work, and you cycle these buffers, so with one copy, you go from S3 to GPU, and you have very little CPU overhead during the entire scan.
There's one more iteration of this, but you have to get rid of TCP. If you have a very expensive and tedious setup, but really performant and cool RDMA object store in your hands, then you can use RDMA protocol, which is different from TCP, that it doesn't have to go to the kernel sockets layer. It can bypass the CPU entirely because it works as two peers connected via some sort of fiber, can be PCIe, can have some network connection, can be in between, but they can read and write from each other's memory regions. If you have this ability, then the GPU itself can dispatch the I/O request on your behalf. If it does it, it not only bypasses all CPU, it even bypasses the PCIe hub of your CPU, because these boxes come with multiple GPUs and come with many network cards. Most often, each GPU has an associated network card, so the RDMA would flow through from the GPU into the nearest network card, bypassing your CPU PCIe hub all together, so it's basically free. The CPU doesn't even know what's happening. For this, the network card also needs to be smart enough, but most boxes that come with high-end GPUs have that network card in place already.
Sketch of one byte range travelling from the network card to GPU memory in five rows. Today: five copies through kernel, userland, rustls, reqwest, Vortex, and a CUDA bounce buffer. A pinned buffer pool removes the bounce buffer. kTLS removes the ciphertext copy. A custom HTTP client leaves one copy. RDMA leaves zero and skips the CPU.
With all that, let's revert back to the video. Now, you can see there is some byte range coalescing, so we have a filter or projection. In this case, we have no filter, and we SELECT *, essentially, all columns, red, green, and blue. They all go through this coalescing layer. Then we fetch these bytes from S3, and then we cycle the byte buffer that we read them through, and they go only one copy into the GPU, and then the GPU processes them as 15 gigabits per second, 4K, 60 hertz, and then we stream it back to see what's happening. What's also cool about this, because while we have it, we can change the filters and projections. We can do, for example, this. This is not a post projection. This is actually a combination of a projection and a custom Vortex expression. I have written this custom expression called quantise, and I've written a GPU kernel for it, which is very basic.
Quantise just gets the colors, each pixel value is 0 to 55, and then it snaps it to three levels, basically, depending on which level it's closest to. You get this effect of having only three columns. We also pruned the blue column out of the way, just for fun, so it's more yellowy, but also this discretized footage we have. This is, importantly, mostly handled by Vortex orchestration. You don't tie this kernel at the end yourself. You just say that I want this query, and the scan orchestration takes care of appending your kernel, and then dispatching work onto your kernel, and then you can keep the same throughput. Or you can do something like this. This is a bit jarring to watch, because the video skips all over. The way this works, remember I told you when we have this file with three columns? This file has four columns, and the fourth column is whether the frame has a cyclist or not.
I've run this object recognition algorithm per frame, and then we now have this has_bike Boolean column, as the fourth column, and I'm filtering to only the frames that has a bike in it. The way this works is because each frame is huge. Each frame we have is 8 megabytes. We can afford to have a zone per frame. In the layout level, we have a zone-map that is as granular as it gets. We have one row per frame, and so we can prune each individual frame that doesn't have a bicyclist on it.
Maybe what's more important to talk about here is all of these changes don't affect throughput. You can come up with an arbitrary filter. You can come up with your own projection. You can combine them, but you can keep the same throughput, and you only read the data that you need. Remember I told you the videos were 60 frames per second because I was intentionally slowing them down. Normally, when you let them loose and then write it to like a file, then we can stream 240 frames per second, because I was pacing the video so we can see it at 60 frames per second, but we can sustain 60 gigabits per second end-to-end with Vortex.

What is Possible?

I want to talk very briefly on what you can do with Vortex if you want to customize it. Remember I told you, you can have your own arrays and you can have your own layouts. A layout is responsible for I/O and then logical plan and then pruning and all that stuff, but it's also responsible for placement. There's nothing in Vortex file spec that enforces you to be in the same physical file. You can separate your segments into two. You can get the pruning segments and store them in Redis, and you can get all the array segments in S3. Redis has one-hundredth of the latency of S3, so you get pruning nearly immediately compared to the numbers you would get with S3, and then you get the S3 throughput on streaming the data to the end. Or, say you have this giant instance with multiple high-end GPUs but a single CPU, and then you have this GPU analytics engine that you shard the data into individual GPUs, and then you run a scan, and then dispatch the scan into individual shards.
What if the query has an aggregation? Then you need to aggregate all of the results into a single GPU. You can model that aggregation as an RDMA scan, because GPUs are connected to each other with an extremely fast fiber called NVLink. It can sustain tremendous speeds. You can scan from one GPU to your central GPU to aggregate, and you can scan in terabits per second for the segregation.

Takeaways

That was Vortex. I wanted to talk about how it allows you to change your filter, to change your data mix and not reprocess everything over and again. I also want to mention that you can read from S3 into GPU around network card speeds. It can be 60 gigabits per second, can be more if you have a stronger network card. You can read from RDMA sources to your GPU at whatever PCIe connection you have, and that can be 500 gigabits or terabits with the newest PCIe.