Skip to content

Instantly share code, notes, and snippets.

@tomaka
Last active June 22, 2022 16:06
Show Gist options
  • Star 19 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save tomaka/da8c374ce407e27d5dac to your computer and use it in GitHub Desktop.
Save tomaka/da8c374ce407e27d5dac to your computer and use it in GitHub Desktop.
Rust problem

Here is a pattern that you would do in C and that you can't reproduce in Rust without a relatively big overhead.

(note that everything is pseudo-code, these are not the actual vulkan functions names and a lot of parameters are missing)

How to use Vulkan in C/C++

For the sake of this example, let's create a program and a command buffer:

unsigned long long program_id;
vkCreateProgram(vulkan_context, &program_id);

unsigned long long cb_id;
vkCreateCommandbuffer(vulkan_context, &cb_id);

The API manipulates identifiers whose meanings are implementation-defined, but in practice they are probably just raw pointers.

A Vulkan "program" contains code that is executed by the GPU and tells it how it should render 3D objects. A "command buffer" is a list of commands that the GPU must execute. In order to tell the GPU to draw something, we need to add an entry in the command buffer, and this entry contains the program.

vkCommandBufferStart(cb_id);
vkCommandBufferBindProgram(cb_id, program_id);
vkCommandBufferDraw(cb_id);
vkCommandBufferEnd();

This operation stores a pointer to the program in the commands buffer.

The Vulkan API is very low level, and one of its particularities is that you (the programmer) need to make sure that you don't modify or delete the program while the command buffer is still alive and/or in use. If you do that, you will most likely get a crash.

Translating that to Rust

In order to write a safe Rust wrapper around Vulkan, the idiomatic way would be to do this:

pub struct Program {
    id: u64,
}

impl Program {
    pub fn new(vulkan_context: &Context) -> Program {
        let mut id = mem::uninitialized();
        vkCreateProgram(vulkan_context, &mut id);
        Program { id: id }
    }
}

pub struct CommandBuffer<'a> {
    id: u64,
    dependencies: PhantomData<&'a ()>,
}

impl<'a> CommandBuffer<'a> {
    pub fn new_draw_command(vulkan_context: &Context, program: &'a Program) -> CommandBuffer<'a> {
        let mut id = mem::uninitialized();
        vkCreateCommandbuffer(vulkan_context, &mut id);
        vkCommandBufferStart(id);
        vkCommandBufferBindProgram(id, program.id);
        vkCommandBufferDraw(id);
        vkCommandBufferEnd();
        CommandBuffer { id: id, dependencies: PhantomData }
    }
}

Thanks to the lifetime, you're safe, as you prevent the user from deleting/modifying the program while the command buffer is alive.

However, using a lifetime also means you can't write that:

struct MyAssets {
    program: Program,
    command_buffer: CommandBuffer<???>,    // missing lifetime parameter
}

You must write that instead:

struct MyAssets1 {
    program: Program,
}

struct MyAssets2<'a> {
    command_buffer: CommandBuffer<'a>,
}

Why it is a problem

This example was just about programs and command buffers.

In the real Vulkan API, you have:

  • Devices
  • Programs (would contain a &Device)
  • Buffers (would contain a &Device)
  • Textures (would contain a &Device)
  • Fixed pipeline states (would contain a &Device)
  • Program pipelines (would contain a &Program and several &FixedPipelineStates)
  • Descriptor sets (would contain several &Buffers and &Textures, and a &ProgramPipeline)
  • Command buffers (would contain a &DescriptorSet)
  • (some others like fences, samples or queues that I'm not including for the sake of simplicity)

Because you can't store the borrower and borrowed in the same struct, you would end up with something like this:

struct Device {
    device: Device,
}

struct MyAssets1<'a> {
    programs: Vec<Program<'a>>,
    buffers: Vec<Buffer<'a>>,
    textures: Vec<Texture<'a>>,
}

struct MyAssets2<'a> {
    program_pipelines: Vec<ProgramPipeline<'a>>,
}

struct MyAssets3<'a> {
    descriptor_sets: Vec<DescriptorSet<'a>>,
}

struct MyAssets4<'a> {
    command_buffers: Vec<CommandBufer<'a>>,
}

Meanwhile in C/C++, you would just put everything in a big struct and handle the lifetimes in an unsafe manner.

The bigger problem is that the fact that you have five different structs that depend on one another "propagates" thoughout the API layers.

If you write a library that abstracts over vulkan-rs, you will need to have five different structs as well. If you write another library that abstracts over that library, you will need to have five different structs as well. The fact that you use Vulkan should be an implementation detail, and it would be confusing if the user had to create 5 randomly-named structs on the stack just to make it work.

The overhead

As far as I know the only way to solve this problem for the moment while remaining safe is to use a reference counter (Arc). Alternatively there is a known trick with RefCell to bypass the restriction, but it's not practical to use.

The overhead of using an Arc is big compared to a simple PhantomData<&'a ()>, because:

  • You do a memory allocation.
  • You do atomic operations.
  • You increase the chances of cache misses, as the object identifiers are on the heap.

All while a PhantomData is totally free and doesn't even exist at all at runtime.

Conclusion

I don't have any suggestion about how to solve this problem (I have already thought about this problem in the past).

For the moment my plan for a potential Vulkan wrapper library is to use generic pointers, like this:

pub struct CommandBuffer<P> where P: Deref<Program> {
    command_buffer: u64,
    program: P,
}

So that the user can choose between using a &'a Program (low-overhead, low usability) or a Arc<Program> (high overhead, high usability). But this solution isn't great.

@diwic
Copy link

diwic commented Jun 27, 2016

I've also made a crate now. It's alpha quality right now, and ergonomics and performance could probably be improved, but it should generate a safe abstraction. Here it is: refstruct

@Mixthos, you might be interested in checking it out?

@neuronsguy
Copy link

Any word on what the best way to deal with this problem is currently? It seems to be a common feature of wrapping low-level C apis (like drivers).

@oconnor663
Copy link

Could you use a collection of slab allocators hidden somewhere in the context, with objects holding each other's slab handles instead of unsafe pointers? Everything still ends up on the heap, but hopefully with better cohesion?

@matklad
Copy link

matklad commented Nov 11, 2017

For posterity, Rental crate somewhat alleviates this problem.

@cramertj
Copy link

Checking back in here-- it's probably worth playing around w/ this an Pin (though I bet whatever discoveries are made in Rental will probably be applicable here).

@oconnor663
Copy link

Alternatively there is a known trick with RefCell to bypass the restriction, but it's not practical to use.

Also from the future, what was this trick?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment