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

feat(core) deno_core::extension! macro to simplify extension registration #18210

Merged
merged 50 commits into from
Mar 17, 2023

Conversation

mmastrac
Copy link
Contributor

@mmastrac mmastrac commented Mar 15, 2023

This implements two new macros for extension registration to reduce a lot of the boilerplate and open the door for some future improvements:

  • deno_core::ops! registers a block of #[op]s, optionally with type parameters.
  • deno_core::extension! is used to register an extension, and creates two methods that can be used at runtime/snapshot generation time: init_ops and init_ops_and_esm.

An extension may look something like this:

deno_core::extension!(deno_webstorage,
  deps = [ deno_webidl ],
  ops = deno_ops,
  esm = [ "01_webstorage.js" ],
  config = {
    origin_storage_dir: Option<PathBuf>
  },
  state = |state, origin_storage_dir| {
    // ...
  },
);

cli/lsp/tsc.rs Outdated
Comment on lines 2840 to 2851
deno_core::extension!(deno_tsc,
ops = deno_ops,
config = {
performance: Arc<Performance>
},
state = |state, performance| {
state.put(State::new(
Arc::new(StateSnapshot::default()),
performance.clone(),
));
},
);
Copy link
Member

Choose a reason for hiding this comment

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

Sweet! This is exactly what I had in mind. Not sure if config is the name I would go with, but this is the exact idea

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Awesome! We can easily rename config as it's only ever used in the macro. I'm happy to do a bikeshedding phase at the end as the consistent macro layout will make that very easy.

cli/tsc/mod.rs Outdated Show resolved Hide resolved
bench_util/benches/op_baseline.rs Outdated Show resolved Hide resolved
core/modules.rs Outdated Show resolved Hide resolved
ext/node/lib.rs Outdated Show resolved Hide resolved
macro_rules! ops {
($name:ident, parameters = [ $( $param:ident : $type:ident ),+ ], ops = [ $( $(#[$m:meta])* $( $op:ident )::+ $( < $op_param:ident > )? ),+ $(,)? ]) => {
pub(crate) fn $name < $( $param : $type + 'static ),+ > () -> Vec<$crate::OpDecl> {
let mut v = vec![];
Copy link
Member

Choose a reason for hiding this comment

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

Would it be possible to use a static slice here?

Copy link
Member

@bartlomieju bartlomieju Mar 16, 2023

Choose a reason for hiding this comment

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

It's fine to do a vector in the first pass, just asking for a potential followup cleanup/optimization

Copy link
Contributor Author

@mmastrac mmastrac Mar 16, 2023

Choose a reason for hiding this comment

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

I believe that we can use a static slice. I believe we can drastically reduce or eliminate the cloning to populate the state that happens as well.

EDIT: op_xxx::decl() isn't const so this isn't possible (yet)

Copy link
Member

Choose a reason for hiding this comment

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

That would be great to see - even with all the improvements from #18080 there are still visible stack in the flamegraph related to initializing ops. Reducing clones and using static slices should help with that 👍

core/extensions.rs Outdated Show resolved Hide resolved
Comment on lines +42 to +47
config = {
sender: TestEventSender,
fail_fast_tracker: FailFastTracker,
filter: TestFilter,
},
state = |state, sender, fail_fast_tracker, filter| {
Copy link
Member

Choose a reason for hiding this comment

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

@littledivy suggested to me that maybe we should have a single struct instead of config and then state fn would always receive either 1 (state) or 2 (state and config struct) arguments. Any thought @mmastrac?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That would work as well. I actually had that in an earlier pass, but I was worried that having to prefix everything w/cfg. added a lot of noise. I'll circle back at the end on this one as well.

core/extensions.rs Outdated Show resolved Hide resolved
/// * state: a state initialization function, with the signature `fn (&mut OpState, ...) -> ()`, where `...` are parameters matching the fields of the config struct
/// * event_loop_middleware: an event-loop middleware function (see [`ExtensionBuilder::event_loop_middleware`])
#[macro_export]
macro_rules! extension {
Copy link
Member

Choose a reason for hiding this comment

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

this is sophisticated. we need to have expansion tests like in deno_ops, see https://github.com/eupn/macrotest

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@littledivy Do we have a nightly compiler on the CI machines to run the macro expansion tests?

Copy link
Member

Choose a reason for hiding this comment

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

Oh umm yeah we don't use nightly. Let's just have https://github.com/dtolnay/trybuild tests, wdyt?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I like the idea of using macrotest more than trybuild TBH but I think I'd like to split this off into a new PR and experiment a bit. The trybuild tests don't give much signal beyond the codebase compiling and I think there's some value in both seeing the macro output and failing when things change like your nice ops tests.

@bartlomieju
Copy link
Member

Looks great @mmastrac. I think Divy's suggestions are valid - once we address them this should be good to merge.

Copy link
Member

@ry ry left a comment

Choose a reason for hiding this comment

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

I think we should be wary of introducing too much meta-programming. This patch is a net reduction of ~150 lines. But it introduces a lot of complex in macro programming. Because there's no runtime benefit, it's not obvious to me this is a clear win.

@bartlomieju
Copy link
Member

I think we should be wary of introducing too much meta-programming. This patch is a net reduction of ~150 lines. But it introduces a lot of complex in macro programming. Because there's no runtime benefit, it's not obvious to me this is a clear win.

There's no runtime benefit in this PR, but there will be in a follow up - we will reduce amount of vector allocations significantly when registering extensions on startup and thus make it cheaper to create the main worker instance - there's not gonna be that much difference but I expect to see around 0.5ms improvement in the startup time.

@littledivy
Copy link
Member

@bartlomieju The static ops list can done without this macro too.

Moreover, there are places where static array won't work.

fn collect_ops(exts: &mut [Extension]) -> Vec<OpDecl> {
Places where the list is mutated or collected into a new Vec...so I think we should first figure out &'static [OpDecl].

@mmastrac
Copy link
Contributor Author

@bartlomieju The static ops list can done without this macro too.

Moreover, there are places where static array won't work.

fn collect_ops(exts: &mut [Extension]) -> Vec<OpDecl> {

Places where the list is mutated or collected into a new Vec...so I think we should first figure out &'static [OpDecl].

The &'static [OpDecl] problem is pretty hairy to tackle without doing this macro and cleanup first, I think.

Barring const OpDecls being available, we have three options:

  • Create a large allocation at boot time to hold all the OpDecls, and leak that allocation to create a static lifetime reference
  • Use #[ctor] to initialize the ops before rust's main starts0
  • Use OnceCell to initialize a static mut array on first access

I'd lean towards the first one, but regardless of which option we choose, it'll require a massive refactoring across the codebase unless we have all the OpDecls handled in one uniform way as we do with this PR. As all the "fast" extensions in the snapshot now have a common implementation, it would be trivial for us to interrogate the size of the OpDecl list in a const manner, allocate a single chunk of memory for all of them (either at runtime or in the compiler) and fill that chunk of memory in the most efficient way possible.

Without this PR landing in every case, we'd basically be making changes to ~20+ op lists across the whole codebase.

Either way, having a macro that's not perfect and exactly what we'll want six months from now is better than nothing, as iterating and experimenting becomes 20x cheaper.

@bartlomieju
Copy link
Member

Benchmarked against main branch:

./third_party/prebuilt/mac/hyperfine --warmup 10 './target/release/deno run empty.js' './deno_ext run empty.js'
Benchmark #1: ./target/release/deno run empty.js

  Time (mean ± σ):      15.9 ms ±   0.7 ms    [User: 10.6 ms, System: 5.4 ms]

  Range (min … max):    14.9 ms …  19.3 ms

Benchmark #2: ./deno_ext run empty.js

  Time (mean ± σ):      15.6 ms ±   0.6 ms    [User: 10.5 ms, System: 5.4 ms]

  Range (min … max):    14.5 ms …  17.7 ms

Summary

  './deno_ext run empty.js' ran
    1.02x faster than './target/release/deno run empty.js'

This PR cuts startup time by 0.3ms, so IMO it's worth it to land even with additional complexity. It became obvious with Matt's work that there are some unnecessary allocations happening and it's a good forcing function to address them.

@littledivy
Copy link
Member

Any ideas as to where that improvement comes from?

Copy link
Member

@littledivy littledivy left a comment

Choose a reason for hiding this comment

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

Barring const OpDecls being available, we have three options:

I'm not sure I understand. Extensions holding their individual &'static mut [OpDecl] could just work too.

Without this PR landing in every case, we'd basically be making changes to ~20+ op lists across the whole codebase.

Good point.

@mmastrac
Copy link
Contributor Author

I'm not sure I understand. Extensions holding their individual &'static mut [OpDecl] could just work too.

op_foo::decl() isn't const, so we can't easily create static slices. For X extensions we'd have to leak X Boxes, run X #[ctor] macros, or have X OnceCell-protected arrays.

core/extensions.rs Outdated Show resolved Hide resolved
Comment on lines -48 to -49
ops.extend(crate::ops_builtin_v8::init_builtins_v8());
ext.ops(ops)
Copy link
Member

Choose a reason for hiding this comment

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

One reallocation gone!

unsafely_ignore_certificate_errors: Option<Vec<String>>,
) -> &mut ExtensionBuilder {
let mut ops = ops::init::<P>();
ops.extend(ops_tls::init::<P>());
Copy link
Member

Choose a reason for hiding this comment

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

Second reallocation gone 🚀

Copy link
Member

@bartlomieju bartlomieju left a comment

Choose a reason for hiding this comment

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

LGTM, fantastic first PR.

Please update the commit message before landing (ie. remove mention of ops_bundle!)

@littledivy
Copy link
Member

op_foo::decl() isn't const, so we can't easily create static slices. For X extensions we'd have to leak X Boxes, run X #[ctor] macros, or have X OnceCell-protected arrays.

Right. decl() shouldn't be hard to make const though.

deno/core/extensions.rs

Lines 48 to 56 in fe07f6c

pub struct OpDecl {
pub name: &'static str,
pub v8_fn_ptr: OpFnRef,
pub enabled: bool,
pub is_async: bool,
pub is_unstable: bool,
pub is_v8: bool,
pub fast_fn: Option<Box<dyn FastFunction>>,
}

It's just the fast_fn that can be converted, it can be made a fixed sized trait.

Copy link
Member

@littledivy littledivy left a comment

Choose a reason for hiding this comment

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

LGTM 2 - Great PR

@mmastrac mmastrac disabled auto-merge March 17, 2023 17:37
@mmastrac mmastrac enabled auto-merge (squash) March 17, 2023 17:40
@mmastrac
Copy link
Contributor Author

It's just the fast_fn that can be converted, it can be made a fixed sized trait.

This would be great to have. I think with that we could probably move most of the extension setup to compile-time which would be pretty sweet. I wasn't able to get a good handle on what that particular change would require as it went into some of the magic in rusty_v8.

@mmastrac mmastrac merged commit e55b448 into denoland:main Mar 17, 2023
@mmastrac mmastrac deleted the ops_extension branch March 17, 2023 18:26
mmastrac added a commit that referenced this pull request Mar 17, 2023
…8252)

Follow-up to #18210:

* we are passing the generated `cfg` object into the state function
rather than passing individual config fields
 * reduce cloning dramatically by making the state_fn `FnOnce`
 * `take` for `ExtensionBuilder` to avoid more unnecessary copies
 * renamed `config` to `options`
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.

None yet

5 participants