Improving GNU Radio Accelerator Device Dataflow
CC Attribution 2.0 Belgium:
You are free to use, adapt and copy, distribute and transmit the work or content in adapted or unchanged form for any legal purpose as long as the work is attributed to the author in the manner specified by the author or licensor.
Sorber, David
Accelerator devices such as GPUs, FPGAs, or DSPs can be very useful for offloading computationally intensive digital signal processing tasks. Unfortunately, the GNU Radio SDR framework does not directly support such devices. Many workarounds have been developed to allow accelerator devices to be used within GNU Radio, but each comes with performance and/or flexibility tradeoffs. To solve these problems work is currently underway to develop generic support for accelerator devices within GNU Radio itself. The focus of this work is to modify GNU Radio to allow support for custom buffers. Custom buffer support will allow GNU Radio to directly utilize device specific buffers (e.g. DMA buffers) and therefore eliminate the need to double copy in order to move data into and out of an accelerator device. Furthermore, the custom buffer concept can be extended to allow “zero copy” data access between two kernels on the same accelerator device. This presentation will cover the design and current status of custom buffer support for GNU Radio.
 Hello, my name is David Sorber and I'm going to be talking to you today about a project I'm working on to improve the GNU radio accelerator device data flow. As I'm sure many of you are aware, GNU radio is a software-defined radio framework that uses a block-based interface and includes a library of very nice signal processing blocks that can be interconnected in various ways to create signal processing photographs. The image you see there is an example flow graph that I pulled off the GNU radio website. While GNU radio is very nice, one thing that it does not explicitly support are accelerator devices. When I say accelerator devices, I'm talking about things like GPUs, FPGAs, DSPs, and really any other kind of hardware that you might use for signal processing computation. If you're like me, you may have worked around this limitation by creating your own signal processing, accelerated signal processing block using the block interface. If you did that, you probably noticed the same thing I did, which is that the efficiency of the data transfer into and out of the accelerator device is really rather suboptimal. When I say suboptimal, here's specifically what I'm talking about. Many if not most accelerator devices require a dedicated memory buffer to transfer data into and out of the device. These buffers are frequently known as DMA buffers. GNU radio manages its own buffers, so in order to use a device's DMA buffers, you have to copy from the GNU radio buffer into the device's input buffer, for example, and then do the opposite on the output path, copy data from the device's output DMA buffer back into the GNU radio buffer. This is an example of what's known as the dreaded double copy problem. A better way to interface with accelerated devices would be to remove the double copy and allow for the GNU radio framework to directly manipulate device DMA buffers, and that's exactly the purpose of the project that I'm working on. To start, I'd like to talk a little bit more about some background so that you can better understand the details of this project. As I mentioned before, and I'm sure most of you are aware, GNU radio blocks are interconnected to create flow graphs. Under the hood, when blocks are interconnected, the block that's writing output allocates a buffer, and of course, writes to that buffer, and the downstream block that's reading or consuming data from that buffer does so using a buffer reader object that's indicated there in blue. The interconnection between two blocks is represented by a buffer that controls the data moving from one block to the other block. And of course, one upstream block can feed multiple downstream blocks. Here I've shown two in this diagram, but depending on the blocks involved, it can be many more than two. And again, the buffering scheme is fairly similar. The block that's producing output allocates a buffer and writes to it, and the blocks that are consuming the output each have individual read pointers that keep track of their location within that buffer. Another interesting fact about the GNU radio buffers is that they are circular. And I'm talking specifically about the VM CircBuff class, of which there are several implementations that all work in the same general way. And they are very clever circular buffers in that they use a double mapping scheme. So if you see here, the dotted line across the screen represents the boundary between physical and virtual memory. In physical memory, there's a single buffer that's allocated, and in virtual memory, two mappings are created. We'll call them mapping zero and mapping one. And those mappings are placed back to back. What's returned to the user, however, is only the first mapping. So only mapping zero is accessible to the user. However, because mapping one exists, it allows data to be written and automatically wrap around the boundary of the buffer. So for example, if you're starting out at the end of mapping zero and writing across into mapping one, because mapping one is directly behind mapping zero, that has the effect of writing from the end of the buffer directly back to the beginning of the buffer. As I said, this is a very clever and elegant way of handling a doubly mapped buffer. And all of the existing buffers in GNU radio work in this way. So now I'm going to talk about the goals and the overall high level plan for this development project that I'm working on. So one of the most important goals is to preserve compatibility with the existing set of GNU radio blocks. And this is, of course, very important because there's many of them, both that come with GNU radio and as part of various out of tree modules. That's certainly the most important goal here is to preserve compatibility. The next goal is sort of a design goal, which is to create a hardware agnostic interface to allow users to define and use custom buffers. And those custom buffers would typically be things like DMA buffers that I was talking about earlier. Another key point here is that the goal is very much to make this hardware agnostic, not to create a specific interface for any specific type of hardware, but to create a more general one that can be used with lots of different kinds of hardware. And of course, another goal here is to not work in isolation, but to collaborate with the GNU radio development team, which I have been doing. And of course, with the ultimate goal of upstreaming these changes back into GNU radio. In terms of my high level development plan, I've sort of broken it up into two milestones just to make things more manageable. So the first milestone that I'm just calling milestone one is to implement that custom buffer interface that eliminates the double copy problem. And then milestone two is a more forward thinking thing to extend that same interface to support device to device transfers. And I'll talk a little bit about milestone two later on. So milestone one is what I'm currently working on. I started last year and in early December, I posted what I'm calling my first draft of the implementation to the NG sketch GitHub repository at the URL on your screen. I am currently working on the next draft as of the time that this video is created. Those changes are not yet posted in the GitHub repository, but I hope that by the time you're seeing this or shortly thereafter, they will be available. And next, I'm going to talk a little bit more in detail about what milestone one involves, which is first to refactor the existing buffer interface and create the abstraction for single map buffers, then to create an interface for blocks to allocate custom buffers. And then of course, to test the single map buffers, test the performance because that's of course the whole point of this project. And then furthermore to test with many different kinds of hardware to make sure that it's compatible with many different types of hardware and their associated runtimes. So to start off with NG New Radio, there is a buffer class that has the interface presented in the box on the right there. And I started by making that buffer class an abstract class and then breaking it into two pieces. So the two pieces are the buffer double mapped, which represents what the buffer class was originally as an interface to the VM Circular Buffer that I mentioned earlier. And the newly created buffer single mapped, which is the new abstraction for single map buffers. And the idea here is that the single map buffers will wrap a custom buffer. One nice thing to note is that the single map buffer implementation can be tested just with regular character array buffers. They don't actually have to be any kind of special buffer. And now the make buffer factory is actually more of a factory because depending on your selection it now allocates one of these two different classes. I also did something very similar for the buffer reader class. There's only one important function in this interface. So I kept the buffer reader class as is and made the items available function virtual. I then created the buffer reader SM for single mapped class that overrides the items available function. And again, the buffer reader, sorry, buffer add reader function is now more of a factory and that it chooses which of these classes to instantiate. Another important detail for this project to keep in mind is that accelerated blocks may need to allocate their upstream buffers in addition to their downstream buffers. As you noticed earlier, the device needed an input DMA buffer and an output DMA buffer. Also in the GNU radio world that would be an upstream buffer and a downstream buffer, which is to say a buffer that's being read from and a buffer that's being written to. So I also added an interface to allow this to happen. Basically only when it needs to happen, which is to say only when an accelerated block is present, there's now a replace buffer function that will allow that accelerated block to replace its upstream buffer with a custom buffer that it can use. Now I'm going to talk a little more in a little more detail about the single mapped buffer. So one of the key differences between a single mapped buffer and a double mapped buffer is that the single mapped buffer has to manage wrapping around the end of the buffer explicitly. As I mentioned with the double mapped buffer, it's very elegant implementation means that you don't have to explicitly worry about the wrapping case, but single mapped buffers of course are not worked differently and you do have to worry about that case. Another thing that comes into play here is that the size alignment between the producer and the consumer, which is to say the block that's writing and reading becomes important. For many simple cases, the writer is writing in increments of one and the reader is reading in increments of one and everything works out very well. In more complicated scenarios, those read and write granularities may not be one and that leads to different problems, especially with regard to wrapping on a single mapped buffer. So for the first draft of this project that I was working on, I attempted to determine the read and write granularities and size the buffer such that they would never get stuck so that wrapping could be handled automatically without any blocking. However, that proved to be very difficult and in fact, eventually I determined it was impossible with the current interfaces. So I took a new approach and added some functions to explicitly handle the cases where the input or the output might get blocked. So this is in the next draft of these changes. So first off, I make a reasonable attempt at size alignment, although it may not be perfect. And then like I said, I added explicit callbacks, output blocked callback to the buffer class and an input blocked callback to the buffer reader class to handle those two cases. So the output blocked case would look something like this. So consider a case where your write granularity is three and your read granularity is one. And in this case, you can see the write pointers at the end of the single map buffer and it only has space to write two items, but it needs to write three. So it's blocked. So the idea here is that you would find the lowest read pointer that may still have data to read and copy that data back to the beginning of the buffer and then reset the read and write pointers. And as you can see, the read pointer gets reset to zero in this case. And then the write pointer is now repositioned so that it can continue writing. Now is unblocked. Similarly the input blocked case is slightly more complicated, but ultimately very similar. So if you consider this case where the write granularity is one and the read granularity is three, here the read pointer is at the end of the buffer and it has two items until it hits the end of the buffer. Although if you look back at the beginning of the buffer, there's now plenty more items. So it could actually continue reading if it weren't for the fact that it's blocked at the end of the buffer. So the idea here is that the pending write or read data at the beginning of the buffer gets moved down and then the read data at the end of the buffer gets moved back to the beginning of the buffer. And again, the pointers get adjusted. So the read pointer ends up back at the beginning of the buffer and the write pointer gets moved down to the next available position. And now the input becomes unblocked. So the current status of these changes is that testing is ongoing. What I've done is temporarily modify the GNU radio runtime so that all buffers are allocated using the single map buffer implementation. And then I've been running the existing GNU radio tests. And those present a lot of interesting cases that I've been working through. I've got most of them worked out, but there's still a few that are giving me trouble that still need to be resolved. So that's where things are at right now. And like I said, once those issues are resolved, then the next draft will be available. In addition to the single mapped buffer implementation, there's also another interface that allows blocks to actually allocate custom buffers. I haven't put a lot of detail into this presentation because the interface that I created for the first draft is going to be revised for the second draft. So I will mention what I did, but I'm not going to go into too much detail because it's going to change. So what I did is I created three virtual functions and added them to the block class. And by virtue of the fact that they're virtual functions, that means that that didn't break anything. And the three functions do three very simple things. So the first one returns the type of buffer, which is used by the setup functions to determine if a buffer needs to be allocated. The next one is the function that actually allocates the buffer, and the third is the function that frees the buffer. So this is a very simple interface, but I knew upfront that it lacked flexibility, and that was kind of the design tradeoff there. So I did receive some very good feedback on this first draft, and basically that feedback acknowledged that it was a simple interface, but did ask for more flexibility, which I agree with. So I'm going to be working on the next draft to hopefully balance these two ideas and keep a simple interface while adding additional flexibility. So I will mention, for Milestone 2, this is the sort of the next evolution of the same concept, which is to support back-to-back accelerated blocks and allow data to be transferred between them without moving that data back to the host. So as you can see here, and again I'm just going to say as a standard disclaimer, this assumes that both of these blocks are on the same device, that's important of course, but if you were to have two blocks back-to-back like this, in theory they could exchange their data without actually moving the data back to the host, and that's really the whole purpose of Milestone 1 is to add support for device-to-device transfers. So I will say with the changes in Milestone 1, this type of flow graph will be supported, but going between the two accelerated blocks, the data will go back to the host and then back out to the device. And again, that's just a limitation of Milestone 1, and eventually the changes in Milestone 2 will eliminate that. And again, this is a very desirable feature that will have many benefits, one of which is that it will allow people to create accelerated blocks that are more modular. Many of the accelerated blocks right now because of this exact problem are very, very complicated things rather than being much more simple modular things that could be pieced together. So Milestone 2 is coming in the future. So that about takes up all my time. Thank you for listening. My name is David Sorber, and the changes that I mentioned are in the GitHub repository at the URL on your screen. Just as a brief recap, the work on Milestone 1 is ongoing, and work on Milestone 2 will be beginning later this year. Thank you for your time.
 Hello, my name is David Sorber and I'm going to be talking to you today about a project  I'm working on to improve the GNU radio accelerator device data flow.  As I'm sure many of you are aware, GNU radio is a software-defined radio framework that  uses a block-based interface and includes a library of very nice signal processing blocks  that can be interconnected in various ways to create signal processing photographs.  The image you see there is an example flow graph that I pulled off the GNU radio website.  While GNU radio is very nice, one thing that it does not explicitly support are accelerator  devices.  When I say accelerator devices, I'm talking about things like GPUs, FPGAs, DSPs, and really  any other kind of hardware that you might use for signal processing computation.  If you're like me, you may have worked around this limitation by creating your own signal  processing, accelerated signal processing block using the block interface.  If you did that, you probably noticed the same thing I did, which is that the efficiency  of the data transfer into and out of the accelerator device is really rather suboptimal.  When I say suboptimal, here's specifically what I'm talking about.  Many if not most accelerator devices require a dedicated memory buffer to transfer data  into and out of the device.  These buffers are frequently known as DMA buffers.  GNU radio manages its own buffers, so in order to use a device's DMA buffers, you have to  copy from the GNU radio buffer into the device's input buffer, for example, and then do the  opposite on the output path, copy data from the device's output DMA buffer back into the  GNU radio buffer.  This is an example of what's known as the dreaded double copy problem.  A better way to interface with accelerated devices would be to remove the double copy  and allow for the GNU radio framework to directly manipulate device DMA buffers, and that's  exactly the purpose of the project that I'm working on.  To start, I'd like to talk a little bit more about some background so that you can better  understand the details of this project.  As I mentioned before, and I'm sure most of you are aware, GNU radio blocks are interconnected  to create flow graphs.  Under the hood, when blocks are interconnected, the block that's writing output allocates  a buffer, and of course, writes to that buffer, and the downstream block that's reading or  consuming data from that buffer does so using a buffer reader object that's indicated there  in blue.  The interconnection between two blocks is represented by a buffer that controls the data  moving from one block to the other block.  And of course, one upstream block can feed multiple downstream blocks.  Here I've shown two in this diagram, but depending on the blocks involved, it can be many more  than two.  And again, the buffering scheme is fairly similar.  The block that's producing output allocates a buffer and writes to it, and the blocks  that are consuming the output each have individual read pointers that keep track of their location  within that buffer.  Another interesting fact about the GNU radio buffers is that they are circular.  And I'm talking specifically about the VM CircBuff class, of which there are several  implementations that all work in the same general way.  And they are very clever circular buffers in that they use a double mapping scheme.  So if you see here, the dotted line across the screen represents the boundary between  physical and virtual memory.  In physical memory, there's a single buffer that's allocated, and in virtual memory, two  mappings are created.  We'll call them mapping zero and mapping one.  And those mappings are placed back to back.  What's returned to the user, however, is only the first mapping.  So only mapping zero is accessible to the user.  However, because mapping one exists, it allows data to be written and automatically wrap around  the boundary of the buffer.  So for example, if you're starting out at the end of mapping zero and writing across  into mapping one, because mapping one is directly behind mapping zero, that has the effect of  writing from the end of the buffer directly back to the beginning of the buffer.  As I said, this is a very clever and elegant way of handling a doubly mapped buffer.  And all of the existing buffers in GNU radio work in this way.  So now I'm going to talk about the goals and the overall high level plan for this development  project that I'm working on.  So one of the most important goals is to preserve compatibility with the existing set of GNU  radio blocks.  And this is, of course, very important because there's many of them, both that come with  GNU radio and as part of various out of tree modules.  That's certainly the most important goal here is to preserve compatibility.  The next goal is sort of a design goal, which is to create a hardware agnostic interface  to allow users to define and use custom buffers.  And those custom buffers would typically be things like DMA buffers that I was talking  about earlier.  Another key point here is that the goal is very much to make this hardware agnostic, not  to create a specific interface for any specific type of hardware, but to create a more general  one that can be used with lots of different kinds of hardware.  And of course, another goal here is to not work in isolation, but to collaborate with  the GNU radio development team, which I have been doing.  And of course, with the ultimate goal of upstreaming these changes back into GNU radio.  In terms of my high level development plan, I've sort of broken it up into two milestones  just to make things more manageable.  So the first milestone that I'm just calling milestone one is to implement that custom  buffer interface that eliminates the double copy problem.  And then milestone two is a more forward thinking thing to extend that same interface to support  device to device transfers.  And I'll talk a little bit about milestone two later on.  So milestone one is what I'm currently working on.  I started last year and in early December, I posted what I'm calling my first draft of  the implementation to the NG sketch GitHub repository at the URL on your screen.  I am currently working on the next draft as of the time that this video is created.  Those changes are not yet posted in the GitHub repository, but I hope that by the time you're  seeing this or shortly thereafter, they will be available.  And next, I'm going to talk a little bit more in detail about what milestone one involves,  which is first to refactor the existing buffer interface and create the abstraction for single  map buffers, then to create an interface for blocks to allocate custom buffers.  And then of course, to test the single map buffers, test the performance because that's  of course the whole point of this project.  And then furthermore to test with many different kinds of hardware to make sure that it's compatible  with many different types of hardware and their associated runtimes.  So to start off with NG New Radio, there is a buffer class that has the interface presented  in the box on the right there.  And I started by making that buffer class an abstract class and then breaking it into  two pieces.  So the two pieces are the buffer double mapped, which represents what the buffer class was  originally as an interface to the VM Circular Buffer that I mentioned earlier.  And the newly created buffer single mapped, which is the new abstraction for single map  buffers.  And the idea here is that the single map buffers will wrap a custom buffer.  One nice thing to note is that the single map buffer implementation can be tested just  with regular character array buffers.  They don't actually have to be any kind of special buffer.  And now the make buffer factory is actually more of a factory because depending on your  selection it now allocates one of these two different classes.  I also did something very similar for the buffer reader class.  There's only one important function in this interface.  So I kept the buffer reader class as is and made the items available function virtual.  I then created the buffer reader SM for single mapped class that overrides the items available  function.  And again, the buffer reader, sorry, buffer add reader function is now more of a factory  and that it chooses which of these classes to instantiate.  Another important detail for this project to keep in mind is that accelerated blocks  may need to allocate their upstream buffers in addition to their downstream buffers.  As you noticed earlier, the device needed an input DMA buffer and an output DMA buffer.  Also in the GNU radio world that would be an upstream buffer and a downstream buffer,  which is to say a buffer that's being read from and a buffer that's being written to.  So I also added an interface to allow this to happen.  Basically only when it needs to happen, which is to say only when an accelerated block is  present, there's now a replace buffer function that will allow that accelerated block to  replace its upstream buffer with a custom buffer that it can use.  Now I'm going to talk a little more in a little more detail about the single mapped  buffer.  So one of the key differences between a single mapped buffer and a double mapped buffer is  that the single mapped buffer has to manage wrapping around the end of the buffer explicitly.  As I mentioned with the double mapped buffer, it's very elegant implementation means that  you don't have to explicitly worry about the wrapping case, but single mapped buffers of  course are not worked differently and you do have to worry about that case.  Another thing that comes into play here is that the size alignment between the producer  and the consumer, which is to say the block that's writing and reading becomes important.  For many simple cases, the writer is writing in increments of one and the reader is reading  in increments of one and everything works out very well.  In more complicated scenarios, those read and write granularities may not be one and  that leads to different problems, especially with regard to wrapping on a single mapped  buffer.  So for the first draft of this project that I was working on, I attempted to determine  the read and write granularities and size the buffer such that they would never get  stuck so that wrapping could be handled automatically without any blocking.  However, that proved to be very difficult and in fact, eventually I determined it was  impossible with the current interfaces.  So I took a new approach and added some functions to explicitly handle the cases where the input  or the output might get blocked.  So this is in the next draft of these changes.  So first off, I make a reasonable attempt at size alignment, although it may not be  perfect.  And then like I said, I added explicit callbacks, output blocked callback to the buffer class  and an input blocked callback to the buffer reader class to handle those two cases.  So the output blocked case would look something like this.  So consider a case where your write granularity is three and your read granularity is one.  And in this case, you can see the write pointers at the end of the single map buffer and it  only has space to write two items, but it needs to write three.  So it's blocked.  So the idea here is that you would find the lowest read pointer that may still have data  to read and copy that data back to the beginning of the buffer and then reset the read and  write pointers.  And as you can see, the read pointer gets reset to zero in this case.  And then the write pointer is now repositioned so that it can continue writing.  Now is unblocked.  Similarly the input blocked case is slightly more complicated, but ultimately very similar.  So if you consider this case where the write granularity is one and the read granularity  is three, here the read pointer is at the end of the buffer and it has two items until  it hits the end of the buffer.  Although if you look back at the beginning of the buffer, there's now plenty more items.  So it could actually continue reading if it weren't for the fact that it's blocked at  the end of the buffer.  So the idea here is that the pending write or read data at the beginning of the buffer  gets moved down and then the read data at the end of the buffer gets moved back to the  beginning of the buffer.  And again, the pointers get adjusted.  So the read pointer ends up back at the beginning of the buffer and the write pointer gets moved  down to the next available position.  And now the input becomes unblocked.  So the current status of these changes is that testing is ongoing.  What I've done is temporarily modify the GNU radio runtime so that all buffers are allocated  using the single map buffer implementation.  And then I've been running the existing GNU radio tests.  And those present a lot of interesting cases that I've been working through.  I've got most of them worked out, but there's still a few that are giving me trouble that  still need to be resolved.  So that's where things are at right now.  And like I said, once those issues are resolved, then the next draft will be available.  In addition to the single mapped buffer implementation, there's also another interface that allows  blocks to actually allocate custom buffers.  I haven't put a lot of detail into this presentation because the interface that I created for the  first draft is going to be revised for the second draft.  So I will mention what I did, but I'm not going to go into too much detail because it's  going to change.  So what I did is I created three virtual functions and added them to the block class.  And by virtue of the fact that they're virtual functions, that means that that didn't break  anything.  And the three functions do three very simple things.  So the first one returns the type of buffer, which is used by the setup functions to determine  if a buffer needs to be allocated.  The next one is the function that actually allocates the buffer, and the third is the  function that frees the buffer.  So this is a very simple interface, but I knew upfront that it lacked flexibility,  and that was kind of the design tradeoff there.  So I did receive some very good feedback on this first draft, and basically that feedback  acknowledged that it was a simple interface, but did ask for more flexibility, which I  agree with.  So I'm going to be working on the next draft to hopefully balance these two ideas and keep  a simple interface while adding additional flexibility.  So I will mention, for Milestone 2, this is the sort of the next evolution of the same  concept, which is to support back-to-back accelerated blocks and allow data to be transferred  between them without moving that data back to the host.  So as you can see here, and again I'm just going to say as a standard disclaimer, this  assumes that both of these blocks are on the same device, that's important of course, but  if you were to have two blocks back-to-back like this, in theory they could exchange their  data without actually moving the data back to the host, and that's really the whole  purpose of Milestone 1 is to add support for device-to-device transfers.  So I will say with the changes in Milestone 1, this type of flow graph will be supported,  but going between the two accelerated blocks, the data will go back to the host and then  back out to the device.  And again, that's just a limitation of Milestone 1, and eventually the changes in Milestone  2 will eliminate that.  And again, this is a very desirable feature that will have many benefits, one of which  is that it will allow people to create accelerated blocks that are more modular.  Many of the accelerated blocks right now because of this exact problem are very, very complicated  things rather than being much more simple modular things that could be pieced together.  So Milestone 2 is coming in the future.  So that about takes up all my time.  Thank you for listening.  My name is David Sorber, and the changes that I mentioned are in the GitHub repository at  the URL on your screen.  Just as a brief recap, the work on Milestone 1 is ongoing, and work on Milestone 2 will  be beginning later this year.  Thank you for your time.
fsr_improving_gnu_radio_accelerator_device_dataflow_0_179.png fsr_improving_gnu_radio_accelerator_device_dataflow_180_347.png fsr_improving_gnu_radio_accelerator_device_dataflow_348_863.png fsr_improving_gnu_radio_accelerator_device_dataflow_864_1775.png fsr_improving_gnu_radio_accelerator_device_dataflow_1776_2735.png fsr_improving_gnu_radio_accelerator_device_dataflow_2736_3179.png fsr_improving_gnu_radio_accelerator_device_dataflow_3180_3515.png fsr_improving_gnu_radio_accelerator_device_dataflow_3516_4151.png fsr_improving_gnu_radio_accelerator_device_dataflow_4152_4427.png fsr_improving_gnu_radio_accelerator_device_dataflow_4428_4871.png fsr_improving_gnu_radio_accelerator_device_dataflow_4872_6875.png fsr_improving_gnu_radio_accelerator_device_dataflow_6876_9059.png fsr_improving_gnu_radio_accelerator_device_dataflow_9060_10571.png fsr_improving_gnu_radio_accelerator_device_dataflow_10572_12131.png fsr_improving_gnu_radio_accelerator_device_dataflow_12132_12851.png fsr_improving_gnu_radio_accelerator_device_dataflow_12852_14075.png fsr_improving_gnu_radio_accelerator_device_dataflow_14076_15575.png fsr_improving_gnu_radio_accelerator_device_dataflow_15576_15899.png fsr_improving_gnu_radio_accelerator_device_dataflow_15900_16331.png fsr_improving_gnu_radio_accelerator_device_dataflow_16332_17111.png fsr_improving_gnu_radio_accelerator_device_dataflow_17112_18095.png fsr_improving_gnu_radio_accelerator_device_dataflow_18096_19331.png fsr_improving_gnu_radio_accelerator_device_dataflow_19332_20279.png fsr_improving_gnu_radio_accelerator_device_dataflow_20280_22379.png fsr_improving_gnu_radio_accelerator_device_dataflow_22380_24803.png fsr_improving_gnu_radio_accelerator_device_dataflow_24804_25391.png fsr_improving_gnu_radio_accelerator_device_dataflow_25392_25667.png fsr_improving_gnu_radio_accelerator_device_dataflow_25668_25788.png