Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

High-level dynamic query API #202

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft

Conversation

sdleffler
Copy link
Contributor

This adds a (comparatively high level) API for performing dynamic queries, which is more flexible than (but much less performant than) the approach outlined in #196. The API is based on experiments exposing hecs to Lua as can be found in the repository at https://github.com/sdleffler/hv-dev. Much of it is based heavily on internal use of dynamic types and "trait-object-ifying" the Fetch trait, which has a lot of performance consequences. Performance probably isn't much of a concern for anyone who'd want to use this API to integrate with, say, a scripting language... but if necessary there are points which can be optimized, by pooling allocated memory for dynamic fetches and such.

@kabergstrom
Copy link

Cool stuff! I am interested in implementing an API for accessing ECS data with minimal restrictions, which would help make scripting language APIs less prone to panics or other UB.

Here are some examples: https://gist.github.com/kabergstrom/0629cb8db763081f38c56ab61b178a68

The goal is to be able to write code with looser restrictions than the existing APIs, while sacrificing performance in a few ways. Parallelism will not be possible, and there will be more runtime overhead. Borrowing semantics will be upheld at runtime. Queries must follow visibility rules when adding/removing components/entities during iteration, which is not free.

The changes should enable writing familiar code that closely resembles what can be written in popular game engines where gameplay code is single-threaded-only. The key benefit here compared to other engines is that code can access the same data as the existing parallel APIs, meaning users can migrate code between the APIs depending on the evolving needs of their project.

@sdleffler
Copy link
Contributor Author

Cool stuff! I am interested in implementing an API for accessing ECS data with minimal restrictions, which would help make scripting language APIs less prone to panics or other UB.

Here are some examples: https://gist.github.com/kabergstrom/0629cb8db763081f38c56ab61b178a68

The goal is to be able to write code with looser restrictions than the existing APIs, while sacrificing performance in a few ways. Parallelism will not be possible, and there will be more runtime overhead. Borrowing semantics will be upheld at runtime. Queries must follow visibility rules when adding/removing components/entities during iteration, which is not free.

The changes should enable writing familiar code that closely resembles what can be written in popular game engines where gameplay code is single-threaded-only. The key benefit here compared to other engines is that code can access the same data as the existing parallel APIs, meaning users can migrate code between the APIs depending on the evolving needs of their project.

Sounds interesting, but frankly it's completely orthogonal to my use case. :) I need parallel access simultaneously to scripting access, and dynamic typing support at the query level. You can see https://github.com/sdleffler/hv-dev for a working example of this in action.

@kabergstrom
Copy link

Sounds interesting, but frankly it's completely orthogonal to my use case. :) I need parallel access simultaneously to scripting access, and dynamic typing support at the query level. You can see https://github.com/sdleffler/hv-dev for a working example of this in action.

I'm interested in supporting scripting languages better as a part of improving productivity of gameplay coding, so a dynamically typed API on top of the internals required to implement this will be very valuable.

Could you elaborate on how this is orthogonal to your usecase? What do you mean with "parallel access simultaneously to scripting access"?

@sdleffler
Copy link
Contributor Author

Sounds interesting, but frankly it's completely orthogonal to my use case. :) I need parallel access simultaneously to scripting access, and dynamic typing support at the query level. You can see https://github.com/sdleffler/hv-dev for a working example of this in action.

I'm interested in supporting scripting languages better as a part of improving productivity of gameplay coding, so a dynamically typed API on top of the internals required to implement this will be very valuable.

Could you elaborate on how this is orthogonal to your usecase? What do you mean with "parallel access simultaneously to scripting access"?

Absolutely! In my case, the Lua context is a non-Sync resource, though it is Send. The practical result of this is that any updates which call into Lua in any way have to be done on the thread which owns the Lua context, but while this is going, updates which don't access the Lua context (and have an access pattern which allows them to run in parallel) can continue - such as rendering updates, or processing on entities which are irrelevant to scripting (mass updates of bullets in a bullet hell game), etc. If parallelism is out of the question, this can't happen. But that's not really the big issue at hand.

The big problem with this API for me is that it doesn't make extracting dynamic types (from scripting language values) into static type parameters (such as world.get::<ComponentName>()) any easier. That's the main problem that this PR attempts to solve, as well as dealing with the fact that if you're going to deal with downcasting/TypeIds of a type T, then T must be 'static. As such, any API which produces Refs that have lifetimes cannot easily be used w/ a scripting system, if you want to expose those Refs as objects inside the scripting environment. The DynamicItem type in this PR specifically lacks any lifetime bounds for this reason. DynamicComponent<T>s are typed and lifetime-bound, though, so they have to be used within a Rust scope generated by a weird Lua-Rust-Lua dance (see the example at the bottom of the README here) in order to use a different crate I developed to deal with "elastic" lifetimes, to safely extend them and place them into Lua. This API also does not offer any way to dynamically generate a value which describes what components to query during a query, which is the main thing that this PR supplies. DynamicQuery can be generated from Lua, and then passed to hecs to generate values which are then sent back into Lua. I guess that's the main issue I have with this API - it still relies greatly on static typing for defining what components/types are being extracted where.

@kabergstrom
Copy link

kabergstrom commented Nov 14, 2021

Thanks for the write-up!

Absolutely! In my case, the Lua context is a non-Sync resource, though it is Send. The practical result of this is that any updates which call into Lua in any way have to be done on the thread which owns the Lua context, but while this is going, updates which don't access the Lua context (and have an access pattern which allows them to run in parallel) can continue - such as rendering updates, or processing on entities which are irrelevant to scripting (mass updates of bullets in a bullet hell game), etc. If parallelism is out of the question, this can't happen. But that's not really the big issue at hand.

I think you'll find running things in parallel opportunistically to be problematic due to ordering issues. If positions of bullets are updated in parallel, and scripts are able to interact with inputs to that step, how will scripts know which frame's data they are interacting with?
Single-threaded sections of the frame where high level script logic can execute is not that bad (as proven by other popular engines). Other things like rendering can continue in a pipelined fashion given the required data is extracted from the simulation world at the end of a frame.

The big problem with this API for me is that it doesn't make extracting dynamic types (from scripting language values) into static type parameters (such as world.get::<ComponentName>()) any easier. That's the main problem that this PR attempts to solve, as well as dealing with the fact that if you're going to deal with downcasting/TypeIds of a type T, then T must be 'static. As such, any API which produces Refs that have lifetimes cannot easily be used w/ a scripting system, if you want to expose those Refs as objects inside the scripting environment. The DynamicItem type in this PR specifically lacks any lifetime bounds for this reason. DynamicComponent<T>s are typed and lifetime-bound, though, so they have to be used within a Rust scope generated by a weird Lua-Rust-Lua dance (see the example at the bottom of the README here) in order to use a different crate I developed to deal with "elastic" lifetimes, to safely extend them and place them into Lua. This API also does not offer any way to dynamically generate a value which describes what components to query during a query, which is the main thing that this PR supplies. DynamicQuery can be generated from Lua, and then passed to hecs to generate values which are then sent back into Lua. I guess that's the main issue I have with this API - it still relies greatly on static typing for defining what components/types are being extracted where.

The API I propose will guarantee stable addresses for data within the scope, meaning it'd be OK to remove lifetime specifiers as long as the scope panics on exit if component refs are still active (i.e. someone stores a Ref in a persistent object somewhere). Since Lua scripts are not supposed to hold onto component refs persistently, this seems like an OK compromise.
It'd also be possible to implement the refs such that they are invalidated when the scope exits, and future accesses raise a Lua error or similar.

I think it'd be reasonable to build an API similar to what you have in this PR on top of the internals of the API I'm proposing. It'll allow super-fine-grained borrow checking and no restrictions on add/remove of entities/components. Queries would not need to be scoped or require a closure in Lua.

I believe our goals are aligned, in that we both want improved productivity for gameplay programming. I am interested in enabling productive gameplay coding in both Rust and Lua, while still making it possible to run other code that can access ECS data in parallel.

@sdleffler
Copy link
Contributor Author

I think you'll find running things in parallel opportunistically to be problematic due to ordering issues. If positions of bullets are updated in parallel, and scripts are able to interact with inputs to that step, how will scripts know which frame's data they are interacting with? Single-threaded sections of the frame where high level script logic can execute is not that bad (as proven by other popular engines). Other things like rendering can continue in a pipelined fashion given the required data is extracted from the simulation world at the end of a frame.

Ordering is well-defined with what I'm working on due to dependency constraints between systems. For example, the bullets I'm talking about here are objects which have an extremely limited interaction with scripting; they can be spawned and scripting can define patterns and some very limited behavior, but once spawned, their behavior w.r.t. scripting is limited to callbacks on collision (which may or may not be collected and deferred.) So for example, systems might proceed with entering a section where scripting is given priority to call update methods on components with Lua interaction; new bullets are spawned; then we split in parallel into a section which gathers updates from, say, FMOD, while immediately spitting fired callbacks into Lua while the bulk integration of bullet positions and non-scripting based behavior updates occur. Either way, I'm still experimenting with it and you probably have a much better idea of how well it will work than I do!

The API I propose will guarantee stable addresses for data within the scope, meaning it'd be OK to remove lifetime specifiers as long as the scope panics on exit if component refs are still active (i.e. someone stores a Ref in a persistent object somewhere). Since Lua scripts are not supposed to hold onto component refs persistently, this seems like an OK compromise. It'd also be possible to implement the refs such that they are invalidated when the scope exits, and future accesses raise a Lua error or similar.

This is how DynamicComponent currently works! The latter option, I mean. Though the difference here is that once you borrow a DynamicComponent, its scope doesn't end until the DynamicQueryBorrow is dropped, which is why Lua has to have a closure there - the query borrow is the only thing to have a lifetime on it. Would this "ergonomic API view" be reference-counted? Where would that lifetime go?

I think it'd be reasonable to build an API similar to what you have in this PR on top of the internals of the API I'm proposing. It'll allow super-fine-grained borrow checking and no restrictions on add/remove of entities/components. Queries would not need to be scoped or require a closure in Lua.

It's not impossible to modify the DynamicComponent interface in this PR to function this way. I really like the add/remove view requirements; it would be very useful for my current situation. I do think it's something that can be implemented on top of hecs, though, while dynamic queries can't...

I believe our goals are aligned, in that we both want improved productivity for gameplay programming. I am interested in enabling productive gameplay coding in both Rust and Lua, while still making it possible to run other code that can access ECS data in parallel.

Definitely! :D

@sdleffler
Copy link
Contributor Author

I think I'm starting to see how this proposed API would be so helpful - a dynamic query could immediately run the query, collect all results, and return them, without keeping any actual borrow on the world, since it would basically only be a filtered world.iter() which efficiently grabs just the entities with the relevant components. Since it's a scripting environment, the performance of acquiring borrows on entire archetypes all at once is more than is necessary, so borrowchecking can be done on individual components, and the scope of the API can allow for this access to be "loaned" to Lua for the duration of the scope, and revoke it on drop... Though that has its own safety issues; drop guards which when leaked allow lifetimes to live longer than they should...

@kabergstrom
Copy link

I think I'm starting to see how this proposed API would be so helpful - a dynamic query could immediately run the query, collect all results, and return them, without keeping any actual borrow on the world, since it would basically only be a filtered world.iter() which efficiently grabs just the entities with the relevant components. Since it's a scripting environment, the performance of acquiring borrows on entire archetypes all at once is more than is necessary, so borrowchecking can be done on individual components, and the scope of the API can allow for this access to be "loaned" to Lua for the duration of the scope, and revoke it on drop... Though that has its own safety issues; drop guards which when leaked allow lifetimes to live longer than they should...

Since the API wraps a &mut World, it gets to decide how to make access to the data stored in World safe, and can ignore archetype borrowing. Instead, the API would have a more granular borrow checking mechanism internally for guarding access to memory. The API must only ensure the functions it exposes are sound during the scope, and doesn't have to ensure the regular World APIs are safe to call.

@sdleffler
Copy link
Contributor Author

Since the API wraps a &mut World, it gets to decide how to make access to the data stored in World safe, and can ignore archetype borrowing. Instead, the API would have a more granular borrow checking mechanism internally for guarding access to memory. The API must only ensure the functions it exposes are sound during the scope, and doesn't have to ensure the regular World APIs are safe to call.

What about the lifetime of the wrapper type itself? Since it would wrap &mut World, it would be non-static, which could be problematic for crossing 'static boundaries. Which in my experience is quite common in scripting applications. Common enough that I've needed to build abstractions for crossing them on a regular basis. Would this wrapper type function instead more like a guard that internally holds more of a *mut World rather than an &'a mut World, as well as some kind of flag/borrow counter that helps ensure it's not borrowed when the guard is dropped? I feel like for this kind of thing to be both safe and 'static, the construction API would have to be more of the form of .ergo_scope(|wrapped_world| { ... }) rather than .ergo(), since a drop guard that causes UB on it being leaked is very bad news.

@kabergstrom
Copy link

kabergstrom commented Nov 25, 2021

What about the lifetime of the wrapper type itself? Since it would wrap &mut World, it would be non-static, which could be problematic for crossing 'static boundaries. Which in my experience is quite common in scripting applications. Common enough that I've needed to build abstractions for crossing them on a regular basis. Would this wrapper type function instead more like a guard that internally holds more of a *mut World rather than an &'a mut World, as well as some kind of flag/borrow counter that helps ensure it's not borrowed when the guard is dropped? I feel like for this kind of thing to be both safe and 'static, the construction API would have to be more of the form of .ergo_scope(|wrapped_world| { ... }) rather than .ergo(), since a drop guard that causes UB on it being leaked is very bad news.

Yes, there would be reference+borrow counters internally.
The scope object takes ownership temporarily by swapping the world out of the &mut World:
kabergstrom@95a1652#diff-0d30ac5ffa6e6c74b1c4decd92791de52afa7cf43ad7f88916b60f339338c8afR18

In the Drop impl, if there are active references, it panics which causes it to never swap back ownership. This ensures that the. World cannot escape the scope while there are active references, even if the user mem::forgets the scope (since that would just leak the world and leave the user with an empty world).

Copy link
Owner

@Ralith Ralith left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made an initial pass. Please add:

  • a minimal example that exercises the new capabilities this introduces, e.g. by manipulating a world through an FFI-safe interface.
  • tests that verify proper borrow-check panics

Also, did you consider a more direct AST-based approach in place of relying heavily on dynamic dispatch? What were the tradeoffs that led this way?

empty @ None => empty
.insert(Box::new(value))
.downcast_mut()
.expect("dynamic fetch invalidated!"),
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can't possibly fail, so a message isn't useful.

Suggested change
.expect("dynamic fetch invalidated!"),
.unwrap(),

Comment on lines +407 to +408
/// Create a new `DynamicQuery`. Use this to compose dynamic queries in a similar way to how you
/// would use tuples to compose [`Query`]s.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could use some examples.

}

/// Lift a static [`Query`] into a `DynamicQuery`.
pub fn lift<Q: Query + Send + Sync>() -> Self
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Send + Sync shouldn't be needed here, since values of type Q are never constructed. Instead, StaticFetch should have unsafe impl Send + Sync. Also, it's kind of weird to mix inline bounds with a where clause.

/// Lift a static [`Query`] into a `DynamicQuery`.
pub fn lift<Q: Query + Send + Sync>() -> Self
where
Q: 'static,
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this needed?

};

#[derive(Default)]
pub struct DynamicFetch {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be documented.

}
}

pub trait ErasedFetch<'a>: Send + Sync {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the lifetime 'a here serve a purpose? For Fetch it's used so that Item can have an appropriately bounded lifetime, but here we're dynamically checking those lifetimes anyway.

Comment on lines +398 to +399
/// A dynamic query; the dynamic equivalent of the [`Query`] trait. Used with
/// [`World::dynamic_query`] and [`World::dynamic_query_one`].
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should elaborate on what exactly "dynamic equivalent" means, e.g. by stating that the query has dynamic constraints and as a result yields dynamically-typed items. There should also be discussion of the lifetime of DynamicItem, and detailed discussion of the situations in which all this is needed.

/// As with [`QueryBorrow`](crate::QueryBorrow), borrows are not released until this object is
/// dropped. When this object is dropped, all [`DynamicItem`]s and [`DynamicComponent`]s created
/// from it are invalidated (and if any are still borrowed, a panic will occur.)
pub struct DynamicQueryBorrow<'w> {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to be a separate type from DynamicQueryIter? We do that for static queries so that the dynamic borrow-check is guaranteed to outlive the static borrows yielded by QueryIter, but DynamicQueryIter does not yield static borrows.

}

/// Iterator over the set of entities which satisfy some [`DynamicQuery`].
pub struct DynamicQueryIter<'q> {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My understanding is that the 'static lifetime of DynamicItem is motivated by the requirement to safely pass it to code that is not lifetime-checked (e.g. foreign code). I therefore infer that DynamicQueryIter cannot be passed in that fashion. What impact does this have on the objective of allowing foreign code to query the ECS?

@@ -72,6 +73,10 @@ mod world;
pub use archetype::Archetype;
pub use batch::{BatchIncomplete, BatchWriter, ColumnBatch, ColumnBatchBuilder, ColumnBatchType};
pub use bundle::{Bundle, DynamicBundle, MissingComponent};
pub use dynamic_query::{
DynamicComponent, DynamicItem, DynamicQuery, DynamicQueryBorrow, DynamicQueryIter, DynamicWith,
DynamicWithout, Ref as DynamicItemRef, RefMut as DynamicItemRefMut,
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer not to use as reexports as it makes the rustdocs confusing. In particular, function signatures are not rewritten to use the reexported name, at least last time I checked. Unless that's changed, we should use the desired public name everywhere.

@sdleffler
Copy link
Contributor Author

Thank you for the review! I'm going to try to go through it today.

If I understand exactly what you mean by "a more direct AST-based approach", I went with dynamic dispatch because it ensures that new query combinators didn't have to be added manually to the dynamic query machinery, and allows use of external query combinators without any special machinery. To elaborate on that, I thought that since Fetch is a trait, it seemed to be pointing towards a design choice of "open" rather than "closed" polymorphism for queries. If keeping query combinators "closed" and allowing only a specific non-extendable hecs-defined set of query combinators for dynamic queries is acceptable, then it'd probably be more efficient as well as more elegant to go with an AST-ish definition.

@Ralith
Copy link
Owner

Ralith commented Nov 28, 2021

since Fetch is a trait, it seemed to be pointing towards a design choice of "open" rather than "closed" polymorphism for queries

Fetch is a trait first and foremost because it must be for static queries to bake out nicely at compile time. As with many of hecs' internals, it could theoretically be an extension interface, but isn't currently advertised as such because it's complex and unsafe and hence not something I'm super happy inviting downstream users to deal with.

Critically, it's not clear to what extent clever custom Fetch implementations are useful. From a very abstract standpoint, queries cannot do very much, and a handful of simple operators are therefore sufficient to span the space of expression: atomic shared/unique borrows (&/&mut), atomic non-borrowing presence testing (With/Without, Satisfies), conjunction (tuples), disjunction (Or), and optionality (Option) are together pretty comprehensive. An AST that covers algebraically equivalent cases should be sufficient.

However, #92 establishes weak precedent for getting cheeky here. Maybe there are other cases? Are they worth pursuing, given that we're not currently supporting external extensions here?

Another relevant factor: how do you envision scripting language constructing/executing queries, if at all? Natively lifting a complex query into a dynamic query is a cool capability, but to what extent is it applicable to the forseen use-cases of this feature?

@sdleffler
Copy link
Contributor Author

Another relevant factor: how do you envision scripting language constructing/executing queries, if at all? Natively lifting a complex query into a dynamic query is a cool capability, but to what extent is it applicable to the forseen use-cases of this feature?

This lift-ing is used to construct the "leaf nodes" of the dynamic query syntax tree, in the machinery underlying this example:

// Create a Lua context.
let lua = Lua::new();
// Load some builtin `hv` types into the Lua context in a global `hv` table (this is going to 
// change; I'd like a better way to do this)
let hv = hv::lua::types(&lua)?;

// Create userdata type objects for the `I32Component` defined above as well as a similarly defined
// `BoolComponent` (exercise left to the reader)
let i32_ty = lua.create_userdata_type::<I32Component>()?;
let bool_ty = lua.create_userdata_type::<BoolComponent>()?;

// To share an ECS world between Lua and Rust, we'll need to wrap it in an `Arc<AtomicRefCell<_>>`.
// Heavy provides other potentially more efficient ways to do this sharing but this is sufficient
// for this example.
let world = Arc::new(AtomicRefCell::new(World::new()));
// Clone the world so that it doesn't become owned by Lua. We still want a copy!
let world_clone = world.clone();

// `chunk` macro allows for in-line Lua definitions w/ quasiquoting for injecting values from Rust.
let chunk = chunk! {
    // Drag in the `hv` table we created above, and also the `I32Component` and `BoolComponent` types,
    // presumptuously calling them `I32` and `Bool` just because they're wrappers around the fact we
    // can't just slap a primitive in there and call it a day.
    local hv = $hv
    local Query = hv.ecs.Query
    local I32, Bool = $i32_ty, $bool_ty

    local world = $world_clone
    // Spawn an entity, dynamically adding components to it taken from userdata! Works with copy,
    // clone, *and* non-clone types (non-clone types will be moved out of the userdata and the userdata
    // object marked as destructed)
    local entity = world:spawn { I32.new(5), Bool.new(true) }
    // Dynamic query functionality, using our fork's `hecs::DynamicQuery`.
    local query = Query.new { Query.write(I32), Query.read(Bool) }
    // Querying takes a closure in order to enforce scope - the queryitem will panic if used outside that
    // scope.
    world:query_one(query, entity, function(item)
        // Querying allows us to access components of our item as userdata objects through the same interface
        // we defined above!
        assert(item:take(Bool).value == true)
        local i = item:take(I32)
        assert(i.value == 5)
        i.value = 6
        assert(i.value == 6)
    end)

    // Return the entity we spawned back to Rust so we can examine it there.
    return entity
};

Queries w/ multiple entity look identical in my current implementation, where the only difference is query_one losing the entity parameter and becoming query. lift here provides a single entrypoint for creating a dynamic query from a known static query, and if you have a more complex static query, you can lift it into a single dynamic node instead of having to break it down into multiple dynamic nodes.

As for when you'd actually want to do it, I envision it for prototyping or even debugging, for just quickly extracting a bunch of entity data.

@Ralith
Copy link
Owner

Ralith commented Nov 28, 2021

That example doesn't seem to contain lift at all. I'm guessing it's invoked by Query.write/Query.read? That doesn't seem to take much advantage of lift's generality.

I envision it for prototyping or even debugging, for just quickly extracting a bunch of entity data.

Can you elaborate on when lifting complex static queries in one go is helpful here? My intuition is that if you're writing the whole query in Rust to begin with, discarding type information doesn't do you much good.

@sdleffler
Copy link
Contributor Author

That example doesn't seem to contain lift at all. I'm guessing it's invoked by Query.write/Query.read? That doesn't seem to take much advantage of lift's generality.

Yes; for reference, under the hood, Query.write and Query.read extract a reference to an object of type dyn ComponentType, which is defined w/ a blanket impl like this:

impl<T: ecs::Component + UserData> ComponentType for Type<T> {
    fn type_id(&self) -> TypeId {
        TypeId::of::<T>()
    }

    fn read(&self) -> ecs::DynamicQuery {
        ecs::DynamicQuery::lift::<&T>()
    }

    fn write(&self) -> ecs::DynamicQuery {
        ecs::DynamicQuery::lift::<&mut T>()
    }
    
    // ... There is significantly more to this impl, but it isn't relevant here.
}

They then call .write() or .read() respectively, and the returned DynamicQuery object has its type erased and then makes its way into the scripting context.

I envision it for prototyping or even debugging, for just quickly extracting a bunch of entity data.
Can you elaborate on when lifting complex static queries in one go is helpful here? My intuition is that if you're writing the whole query in Rust to begin with, discarding type information doesn't do you much good.

Sure; if you want to create a query which is dynamically usable and iterates over a bundle, then you can export a DynamicQuery into the scripting language which contains a query with the relevant types. That said, the performance benefit wouldn't be too large there, so there wouldn't be much point unless you specifically needed to create a DynamicQuery there containing types which aren't available to the scripting language, which is possible in a few nasty edge cases such as where the types can't implement the UserData trait I use due to the orphan rules. But then, in that case, you could expose DynamicQuerys which read/write that type without having the actual type object exposed...

What would you prefer over lift?

@Ralith
Copy link
Owner

Ralith commented Nov 28, 2021

I'm just looking for strong motivation for or against the trait object approach vs something more AST-y, where lift would be much less practical to implement. If there's a case where lifting something complex is particularly useful, that would be good motivation for the trait object approach. So far I'm not sure I have a preference; it's possible that an AST approach might be much simpler, but it might also just move the complexity around. match is probably faster than dynamic dispatch, but there's enough other overhead in this path that it may not matter.

If all other factors are a wash, then we might as well have lift; it's awfully neat, after all. Maybe I should find time to prototype the AST approach for empirical comparison...

@All8Up
Copy link

All8Up commented Dec 13, 2021

I'm just looking for strong motivation for or against the trait object approach vs something more AST-y, where lift would be much less practical to implement. If there's a case where lifting something complex is particularly useful, that would be good motivation for the trait object approach. So far I'm not sure I have a preference; it's possible that an AST approach might be much simpler, but it might also just move the complexity around. match is probably faster than dynamic dispatch, but there's enough other overhead in this path that it may not matter.

If all other factors are a wash, then we might as well have lift; it's awfully neat, after all. Maybe I should find time to prototype the AST approach for empirical comparison...

So, I don't have a lot of knowledge of HECS specifics yet which leaves my opinion of this solution fairly undefined. In general though, I wanted to run through my initial thoughts and see where they fit into the two approaches being discussed. Please correct any misconceptions I may have when I outline this because, as stated, still getting up to speed on the code base and so far I'm mostly just working on integration layer items.

The $0.10 cent outline:

  1. Add query_dynamic(&self, &[Access::Read/Write(TypeId)]) to world.
  2. The iterator returned by query_dynamic does not iterate "entities", it only iterates over the filtered archetypes.
  3. The borrow from the iterator returns one slice per matched column and of course a slice for the entity id's. I.e. entity iteration is up to the caller.

The basic thinking with #1 is that at the bottom most level, the only thing needed from what I could see to filter the archetypes and identify columns is the TypeId and read or write access for borrow tracking. The query can also likely be plumbed out to support a 'prepared' version to get a performance benefit like other API's. For #2 & #3 using slices rather than iterators is meant to allow the solution to be used in more use-cases without performance degradation. Specifically, passing one entity over to C/C++ at a time will incur major overhead because the loops won't be unrolled, accessors can't be inlined, etc etc. So, leaving the raw access at this level means that compiled languages can optimize properly. At the same time, a fairly light wrapper over that adds the per entity iteration back in so it acts pretty much like the original API's much as this PR would be exposing.

Overall, I believe this would work and leave the solution open to more use cases without making major changes to hecs itself. If I'm completely out in left field here, let me know, this was my first pass thinking before looking at this PR.

@Ralith
Copy link
Owner

Ralith commented Dec 13, 2021

You can actually accomplish that particular pattern already through the public API. See World::archetypes, Archetype::component_types, and Archetype::get. There's no Archetype::get_mut yet, but if this would address your use case, let me know and I'll happily add it.

@All8Up
Copy link

All8Up commented Dec 13, 2021

You can actually accomplish that particular pattern already through the public API. See World::archetypes, Archetype::component_types, and Archetype::get. There's no Archetype::get_mut yet, but if this would address your use case, let me know and I'll happily add it.

That's very close but it takes a 'T: Component' and returns a typed column which I won't be able to use. Since I'll only likely have a TypeId and a Layout and won't be able to specialize to the component types, the intent was to add a 'get_dynamic(id: TypeId) -> &[u8]' or something like that, and yes, a mut version also. Totally unsafe but since it is going over the FFI boundary, nothing is going to make it safe.

The only thing that is actually a bit worrisome to me is TypeId itself. The std libs bury the details on purpose and yell loudly about "it can change at anytime", so the fact that I'm going to have to transmute those to/from u64 is annoying. I won't be storing them or anything but they could decide to make them u128 or go back to u32 for all we know. So, I'll have to watch for changes each release. :( Hopefully I'll convert most of the C++ to Rust over time but till that is complete it will remain a worry point.

Update: I went ahead and implemented a "get_dynamic_slice(&TypeInfo) -> Option<&[u8]>" and mut variation. I think this is all I need to accomplish my goals with the FFI and if so, I'll push a PR for it.

@Ralith
Copy link
Owner

Ralith commented Dec 13, 2021

the intent was to add a 'get_dynamic(id: TypeId) -> &[u8]' or something like that

Given that your actual types are static, you can type-erase Archetype::get with something like |arch| arch.get::<T>().as_ptr().cast::<u8>(), which could be turned into an extern "C" fn pointer which you can pass to, and usefully invoke from, foreign code. That said, I recognize this is a bit obscure and suboptimal besides, so a more direct approach is welcome.

The only thing that is actually a bit worrisome to me is TypeId itself [...] they could decide to make them u128

There are, in fact, plans to do exactly that. For portability between rust versions, and arguably soundness in general, you shouldn't transmute TypeId at all; instead, you could always handle TypeId values by reference in foreign code, dereffing only in rust.

I went ahead and implemented a "get_dynamic_slice(&TypeInfo) -> Option<&[u8]>"

I think this is unsound because it could construct a reference to uninitialized padding bytes. Returning a raw pointer should be safer.

@All8Up
Copy link

All8Up commented Dec 14, 2021

the intent was to add a 'get_dynamic(id: TypeId) -> &[u8]' or something like that

Given that your actual types are static, you can type-erase Archetype::get with something like |arch| arch.get::<T>().as_ptr().cast::<u8>(), which could be turned into an extern "C" fn pointer which you can pass to, and usefully invoke from, foreign code. That said, I recognize this is a bit obscure and suboptimal besides, so a more direct approach is welcome.

I think we're potentially getting some wires crossed here. The issue is I don't have Rust types at all, these components will be defined in C/C++ until ported over to Rust. So, I'm going to force feed them into hecs by supplying TypeInfo's from the data provided through the FFI. This is only appropriate for things which are fully opaque to the Rust side and are repr(C), meaning that only C will access the components in any manner and any Rust interaction will be via FFI to API's on that side. I'll port as fast as possible but it will take a while as I unwind a fairly large amount of code over there in between other goals.

The only thing that is actually a bit worrisome to me is TypeId itself [...] they could decide to make them u128

There are, in fact, plans to do exactly that. For portability between rust versions, and arguably soundness in general, you shouldn't transmute TypeId at all; instead, you could always handle TypeId values by reference in foreign code, dereffing only in rust.

Yup, but as mentioned above, the components in question won't have any valid type to get a TypeId from on the Rust side, just a layout struct and a drop function. This is not "all" of the components, just a number of them which are too large to port in any reasonable amount of time. I have looked into how the TypeId is generated and it is basically already how I generate the unique identifiers in the C code, barring any actual hash collisions, it will work as a stop gap. Totally evil though.

I went ahead and implemented a "get_dynamic_slice(&TypeInfo) -> Option<&[u8]>"

I think this is unsound because it could construct a reference to uninitialized padding bytes. Returning a raw pointer should be safer.

I'm not sure I see how it would be unsound, totally absolutely without question very unsafe. Underlying the archetype grow_exact is basically the creation of a strided array, it is identical to the underlying code I'm replacing on the C side of the FFI so plugging the data straight through is perfect for that. So long as the C side only accesses repr(C) elements and treats any repr(rust) as opaque, there is no questionable access happening. Maybe I'm missing some finer point here?

Having said this, I am a little concerned with one item; the way the strided array is allocated, hopefully there is a subtle solution I've missed. Basically if you track things back to TypeInfo, the layouts are created directly from the parameterized type and not modified. In grow_exact, that layout is used to create a layout for the entire array container, size simply multiplied by the number of elements. The concern is that at no point could I find it calling "Layout::pad_to_align" or "Layout::array" to guarantee the memory is allocated properly for storage of an array of T's. In "most" cases it probably would not be a problem but I suspect any sufficiently goofy component like an _m128+bool component would end up with insufficient allocated space since it would not add the 15 bytes per component needed to satisfy alignment within the array. Is this correct or did I miss something?

@sdleffler
Copy link
Contributor Author

@All8Up if I understand correctly, you could benefit from a few mechanisms that I use on the regular with regards to dynamically representing types. TypeIds are not the necessary thing here; what you can also use is a fat pointer/pointer to a zero-sized object w/ an attached vtable. I use this mechanism almost everywhere and it's the main one underlying how this PR works. Unsafely using the nightly-only feature flag ptr_metadata you can extract the vtable from such a pointer and use it as a type identifier in your C code, and when passed back into Rust and reconstructed into a trait object it will have the runtime type information you need.

Also, "force-feeding" C data into hecs and providing custom TypeInfo is extremely scary and concerning. A more interesting solution might be to give hecs another key besides TypeInfo to store data; all in all, your requirement sounds EXTREMELY different from what this PR is designed to do, which is a slow and steady scripting interface that doesn't need to be extremely performant and can use this Rust dynamicism to perform the dispatch it needs while maintaining use of Rust's static typing on both sides.

@All8Up
Copy link

All8Up commented Dec 14, 2021

@All8Up if I understand correctly, you could benefit from a few mechanisms that I use on the regular with regards to dynamically representing types. TypeIds are not the necessary thing here; what you can also use is a fat pointer/pointer to a zero-sized object w/ an attached vtable. I use this mechanism almost everywhere and it's the main one underlying how this PR works. Unsafely using the nightly-only feature flag ptr_metadata you can extract the vtable from such a pointer and use it as a type identifier in your C code, and when passed back into Rust and reconstructed into a trait object it will have the runtime type information you need.

I don't believe I can use that solution unfortunately. The point of the FFI for my needs is to get rid of an old crusty piece of C++ that manages archetypes, which is exactly what hecs provides. I'm basically converting to Rust from the inner storage engine outwards. The reason I looked at hecs in the first place is that at the core of the archetype, other than the language in question, it works pretty much identically to the core of the crusty C++ I wrote about 5ish years ago. So, it seems like a pretty brain dead process, I just have to jump through a few hoops for a bit till I port more code to Rust. Also, it was already difficult to sell folks on using Rust in the first place, adding unstable would likely not go over well and even as the boss I won't mandate it.

Also, "force-feeding" C data into hecs and providing custom TypeInfo is extremely scary and concerning.

No doubt, it is "totally, without question, evil and unsafe".. :) But, on the other hand, when 50,000 entities is a "small test" for our simulation work, I can't really afford any added overhead. The reason to do away with the C implementation is the simple fact that due to borrow checking in Rust, the thread safety we require is part of the compiler of course. The biggest ongoing issue is that folks write unsafe components/systems and I have to go spend a week debugging something as silly as "you didn't use an atomic".. When your minimum machine starts with 16 cores and suggested is 32 or 64, these issues are significant.

A more interesting solution might be to give hecs another key besides TypeInfo to store data; all in all, your requirement sounds EXTREMELY different from what this PR is designed to do,

Ah, but I have two requirements. One is very appropriate here which is that the bt (behavior tree) compiler is effectively a script language and after compilation (which I wrote in Rust originally, so that part is already good to go) I have to rebuild the execution pipeline and the per system component access on the fly during hot reloads. The bt's know all the components they can access but based on what a user puts into the nodes, the system(s) have to dynamically query hecs. So, this PR is very much of interest. BT's are just one of several items where the system component access is not known at compile time, so your work here is very much of interest.

I probably should not have posted the initial comment here but in the other PR, I realized the two areas of my needs are too divergent and part of it does not fit this PR. Sorry about that..

@sdleffler
Copy link
Contributor Author

Ah, I see! I didn't realize you had two different things you needed out of this rough patch of feature-space. Makes sense! :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants