-
Notifications
You must be signed in to change notification settings - Fork 37
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
Add Columns
and DerefColumns
derive macros
#315
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The derive code is fine, but I'm worried this is the wrong direction - is there any reason we can't use zerocopy
for most of this functionality?
derive/Cargo.toml
Outdated
@@ -0,0 +1,15 @@ | |||
[package] | |||
name = "zk_evm_derive" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: I'd rather this was called zk-evm-proc-macro
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Happy to call this anything you want, but would underscores be more consistent with the rest of the package names in the workspace?
Also, would you suggest renaming the directory from derive/
to proc_macro/
in this case?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@gio256 Naming it zk_evm_proc_macro
to be consistent and renaming derive
to proc_macro
folder would be great I think. If we would need another project wide proc macro we would then add it here.
Maybe we should put |
derive/src/lib.rs
Outdated
deref_columns::try_derive(ast) | ||
.unwrap_or_else(syn::Error::into_compile_error) | ||
.into() | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would be nice to add some integration test (in the derive/tests
directory) with some dummy structs to regression test basic usecases and demonstrate what macros could do. This is generic macro, it may be used elsewhere.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you do, use trybuild
@0xaatif Zerocopy only works with byte array, right? Here you index and manipulate array of generic type (could be complex, just needs to be |
@0xaatif I'm not sure if zerocopy would be able to help here or not. I suspect we might run into the same issue as you would if you tried to impl Any struct with generic fields is going to be dependently-sized, and you can't safely transmute between dependently-sized types. The reason we can do this is that we know (though the macro does not enforce) that every field is effectively If zerocopy isn't able to help here, another option would be to see if we can get more compile-time guarantees out of the macros here using some of the techniques from zerocopy. |
@gio256, @atanmarko, I could have been more explicit in my suggestion :) Here's a proof-of-concept of what I mean: use std::mem;
use zerocopy::{AsBytes, FromBytes, FromZeroes};
#[derive(FromBytes, FromZeroes, PartialEq, Debug)]
#[repr(C)]
pub struct View<T> {
x: T,
y: T,
z: [T; 5],
}
const WIDTH: usize = 7;
pub fn view<T: AsBytes + FromBytes>(arr: [T; WIDTH]) -> View<T> {
const { assert!(mem::size_of::<View<T>>() == mem::size_of::<[T; WIDTH]>()) };
match View::read_from(arr.as_bytes()) {
Some(it) => it,
None => unreachable!("we statically assert that the width works"),
}
}
// pub fn view_ref
// pub fn view_mut
#[test]
fn test() {
assert_eq!(
view([0, 1, 2, 3, 4, 5, 6]),
View {
x: 0,
y: 1,
z: [2, 3, 4, 5, 6]
}
);
}
AIUI, the pub struct Secp256K1Scalar(pub [u64; 4]); Which should be fine to You'd have write Are there any reasons why this won't work? |
One check of memory byte size equivalence between |
@0xaatif Looks nice. Issue could be that you can not do compile time checks what you can do in the macro, like if structure is with C byte alignment, or if all struct members are of T type. EDIT: Another question, how would you implement with EDIT2: OK, I see |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm satisfied that this isn't the correct approach given our discussion
@0xaatif that looks like a reasonable option to me. It might be worth benchmarking, but from a brief reading of the zerocopy code we would end up with some size/alignment checks followed by a Some points that are probably obvious or insignificant:
|
@0xaatif Reading through the Zerocopy docs (and playing around with a toy example on the side), it looks like we might run into some challenges going in the opposite direction of your example ( I assume that to do this we would need The analysis done by Zerocopy for deriving Obviously column view structs have more than one non-zero-sized field and therefore can't be Because of the way padding is determined for structs, it seems to me that this comes back to the problem that there is no way for Zerocopy to know that all fields of our struct are effectively We could implement |
Makes sense! Looks like those call sites can't be refactored - how about using
|
@0xaatif Can you explain what you mean by "a different trait"? I think using Zerocopy in one direction sounds like a good option. My only concern would be that it might be a tough sell to add a plonky2 dependency so that we can cast an array of field elements as bytes. In the other direction, we could certainly hack something together using Zerocopy and a custom Independently of what we decide on using Zerocopy, I think we have the same problem Zerocopy has -- it's hard to make guarantees about the layout of a generic struct. No matter how we go from |
.github/workflows/ci.yml
Outdated
@@ -151,6 +151,31 @@ jobs: | |||
CARGO_INCREMENTAL: 1 | |||
RUST_BACKTRACE: 1 | |||
|
|||
test_zk_evm_derive: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
notblocking: is there a reason we test crates individually, rather than just cargo test --workspace
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is technical debt I believe, we should unify them all.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
New issue #342
Defining and implementing a different trait to I see where we're stuck r.e uninitialized padding bytes, as I recall zerocopy's authors were waiting from some guarantees from the lang team before making progress on this. I still feel warm about using How big a refactor do you thing changing the callsites will be?
to this
We'd have to be less rusty and pass the &mut in lots of places instead of |
Refactoring to only convert in one direction seems like it could get messy, but maybe that is the "honest" route here. Alternatively, the following is a little hacky but could give us some nice guarantees and allow leveraging zerocopy (still just a sketch). #[repr(C)]
#[derive(Columns)]
pub struct ColumnsView<T> {
a: T,
b: [T; 2],
c: T,
} Turns into this: impl<T> ColumnsView<T> {
// This is just the struct layout algorithm with the loop unrolled.
// https://doc.rust-lang.org/reference/type-layout.html#reprc-structs
const fn __has_padding() -> bool {
const fn __needs_padding<T>(ost: usize) -> bool {
ost % ::core::mem::align_of::<T>() > 0
}
let mut offset = 0;
if __needs_padding::<T>(offset) {
return true;
}
offset += ::core::mem::size_of::<T>();
if __needs_padding::<[T; 2]>(offset) {
return true;
}
offset += ::core::mem::size_of::<[T; 2]>();
if __needs_padding::<T>(offset) {
return true;
}
offset += ::core::mem::size_of::<T>();
__needs_padding::<Self>(offset)
}
// Assert that there is no inter-field padding in the struct layout.
const fn __assert_valid() {
assert!(!Self::__has_padding());
}
}
// This is where this becomes pretty hacky. With `feature[(generic_const_exprs)]`
// this assert could be part of the trait bounds and prevent all misuse. Without
// that, the best I can think of is to hardcode the field and assert that any
// struct deriving `Columns` is safe to use with Goldilocks.
const _: () = ColumnsView::<
::plonky2::field::goldilocks_field::GoldilocksField,
>::__assert_valid();
// This should be safe now (I think).
unsafe impl<T> ::zerocopy::AsBytes for ColumnsView<T>
where
T: ::zerocopy::AsBytes,
[T; 2]: ::zerocopy::AsBytes,
T: ::zerocopy::AsBytes,
{
fn only_derive_is_allowed_to_implement_this_trait() {}
}
// ... other impls |
Plain and simple, we should not take the |
We can't write As for which direction you go, I trust your judgement @gio256 |
Here's my best attempt at a summary of our discussion so far: Currently, all conversions to/from column views are implemented manually using This PR proposes moving these manual impls into a derive macro. It does very little to ensure that these impls are safe for the structs that derive them. The focus is on keeping the macro simple and auditable, but column view structs must be defined and reviewed with the same care as before. It makes sense to also try to make these conversions safer, and there are two different levers we can pull on to do that:
My guess is zerocopy won't be able to support either of these cases until My thoughts on the way forward would be:
|
Reflecting on the I think I'd propose a refactor with a different contract, and keep all the unsafe code out of macros, and away from repeat implementations: use core::{
mem::{self, size_of, MaybeUninit},
ptr,
};
use std::mem::align_of;
/// # Safety
/// - Implementors must be interpetable as an array of `WIDTH` [`Atom`](ArrayLayout::Atom)s.
/// - Implementors must NOT override any of the default items.
///
/// ## Notes on layout
/// - [Size](size_of) is always `>=` [alignment](align_of).
/// - Arrays are laid out with no padding between elements[^1].
/// - `#[repr(C)] struct`s are laid out with each field [aligned](align_of)[^2].
///
/// [^1]: https://github.com/rust-lang/reference/blob/6a0c84af7126dea04c418480216d752385f76493/src/type-layout.md?plain=1#L78
/// [^2]: https://github.com/rust-lang/reference/blob/6a0c84af7126dea04c418480216d752385f76493/src/type-layout.md?plain=1#L205
pub unsafe trait ArrayLayout<const WIDTH: usize>: Sized {
type Atom;
// make life easier for callers.
const WIDTH: usize = WIDTH;
fn as_array_ref(&self) -> &[Self::Atom; WIDTH] {
const { assert::<WIDTH, Self::Atom, Self>() }
// Safety: implementor upholds contract
unsafe { mem::transmute(self) }
}
fn as_array_mut(&mut self) -> &mut [Self::Atom; WIDTH] {
const { assert::<WIDTH, Self::Atom, Self>() }
// Safety: implementor upholds contract
unsafe { mem::transmute(self) }
}
fn into_array(self) -> [Self::Atom; WIDTH] {
const { assert::<WIDTH, Self::Atom, Self>() }
let mut array = MaybeUninit::<[Self::Atom; WIDTH]>::uninit();
// Safety: implementor upholds contract
unsafe { ptr::write(array.as_mut_ptr().cast::<Self>(), self) };
unsafe { array.assume_init() }
}
fn from_array(array: [Self::Atom; WIDTH]) -> Self {
const { assert::<WIDTH, Self::Atom, Self>() }
let mut this = MaybeUninit::<Self>::uninit();
// Safety: implementor upholds contract
unsafe { ptr::write(this.as_mut_ptr().cast::<[Self::Atom; WIDTH]>(), array) };
unsafe { this.assume_init() }
}
fn from_array_ref(array: &[Self::Atom; WIDTH]) -> &Self {
const { assert::<WIDTH, Self::Atom, Self>() }
// Safety: implementor upholds contract
unsafe { mem::transmute(array) }
}
fn from_array_mut(array: &mut [Self::Atom; WIDTH]) -> &mut Self {
const { assert::<WIDTH, Self::Atom, Self>() }
// Safety: implementor upholds contract
unsafe { mem::transmute(array) }
}
}
unsafe impl<T, const N: usize> ArrayLayout<N> for [T; N] {
type Atom = T;
}
#[track_caller]
const fn assert<const WIDTH: usize, AtomT, ArrayLayoutT>() {
if size_of::<[AtomT; WIDTH]>() != size_of::<ArrayLayoutT>() {
panic!("an `impl ArrayLayout` must have the same size as its underlying array")
}
if align_of::<[AtomT; WIDTH]>() != align_of::<ArrayLayoutT>() {
panic!("an `impl ArrayLayout` must have the same alignment as its underlying array")
}
} If we still want a macro, we can have a simple one which does this: const MORE: usize = 3;
#[repr(C)]
struct View<T> {
one: T,
two: [T; 2],
more: [T; MORE]
}
unsafe impl<T> ArrayLayout<{1 + 2 + MORE}> for View<T> {
type Atom = T;
} Which:
The |
I can see the attraction of using a trait given that you can enforce not overriding methods within the crate. Just a few notes:
|
Argh! I was using the keccak as my reference, but now that I see CpuColumnsView, I think you're right - a derive macro wouldn't really help (unless you gave it |
@0xaatif I hadn't thought about requiring annotations on the struct fields. Perhaps there is something useful there. I will leave this PR open for now, but please feel free to close and move discussion elsewhere if you feel confident that another route is better here. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm happy to merge
I think that this PR is clear and good improvement of the existing code, without introduction of the breaking changes. We should discuss further alternatives in the other issue tickets. Just one thing @gio256 - could you rename derive folder and package as discussed above? I think P.S. @muursh or @Nashtare need to approve this PR to be merged. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Trusting your judgement on this!
@gio256 Thank you for all your effort! |
Thanks for the careful review and brainstorming @atanmarko @0xaatif! |
Adds two derive macros,
Columns
andDerefColumns
. The expectation is that column view structs will derive one or both of these in order to cut down on the boilerplate for transmuting to/from arrays.Deriving
Columns
implementsBorrow
,BorrowMut
, andFrom
in both directions as well asIndex
,IndexMut
andDefault
.Deriving
DerefColumns
implementsDeref
andDerefMut
. This was made a separate macro as an extra opt-in step to avoid any confusion resulting from deref coercions.Because this hides unsafe code in a proc macro, the primary goal was auditability. But, we could also trade a bit more complexity in the macros for more flexibility in their usage. This might be useful, for example, if any column structs were expected to use associated constants or const generics in the future as in SP1.
This PR relates to discussion in #312 around implementing column view structs for starks that currently use const indices into their columns. See also #311 for some discussion of whether adding a separate sub-crate for proc macros is actually desirable.