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

[Shared Resources] Embedded host implementation #261

Merged
merged 38 commits into from
Jan 18, 2024

Conversation

jerivas
Copy link
Contributor

@jerivas jerivas commented Nov 13, 2023

Issue

Blocked until proposal is accepted.

I refactored the functions in compile.ts into three files:

  • compiler.ts has common helpers used by both sync and async compilations
  • sync-compiler.ts got the sync functions to implement the Compiler interface
  • async-compiler.ts got the async functions to implement the AsyncCompiler interface

With those changes, compile.ts has become only a thin wrapper around the compiler classes. In local testing the top level compile* functions are still working as usual, but the new classes allow multiple compilations against the same underlying process.

@jerivas
Copy link
Contributor Author

jerivas commented Nov 14, 2023

Copy link
Contributor

@jamesnw jamesnw left a comment

Choose a reason for hiding this comment

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

Nice work! I didn't test to confirm it works, but it sounds like it is working as intended, and the refactoring makes sense to me.

lib/src/async-compiler.ts Outdated Show resolved Hide resolved
lib/src/compile.ts Outdated Show resolved Hide resolved
@jerivas
Copy link
Contributor Author

jerivas commented Nov 14, 2023

Well, this is encouraging! I benchmarked the current implementation of the compile functions, and these are the results:

View benchmark code
import {compare, mark, run} from 'micro-bmark';
import {compileString, compileStringAsync} from './dist/lib/index.mjs';
const styles = '$foo: bar; a {b: $foo}';

await run(async () => {
  mark('compileString', 10, () => compileString(styles));
  mark(
    'compileStringAsync',
    10,
    async () => await compileStringAsync(styles)
  );
});
Benchmarking
compileString x 3 ops/sec @ 287ms/op
compileStringAsync x 4 ops/sec @ 246ms/op

Then I checked out this branch and ran a comparison of the top-level functions and the new compiler methods:

View benchmark code
import {compare, mark, run} from 'micro-bmark';
import {compileString, compileStringAsync} from './dist/lib/index.mjs';

import {initCompiler} from './dist/lib/src/sync-compiler.js';
import {initAsyncCompiler} from './dist/lib/src/async-compiler.js';

const styles = '$foo: bar; a {b: $foo}';
const compiler = initCompiler();
const asyncCompiler = await initAsyncCompiler();

await run(async () => {
  await compare('sync', 10, {
    compileString: () => compileString(styles),
    'compiler.compileString': () => compiler.compileString(styles),
  });

  await compare('async', 10, {
    compileStringAsync: async () => await compileStringAsync(styles),
    'asyncCompiler.compileStringAsync': async () =>
      await asyncCompiler.compileStringAsync(styles),
  });
});

compiler.dispose();
await asyncCompiler.dispose();
Benchmarking
sync
├─compileString x 3 ops/sec @ 290ms/op ± 1.14% (min: 282ms, max: 299ms)
└─compiler.compileString x 52 ops/sec @ 19ms/op ± 172.04% (min: 2ms, max: 158ms)
async
├─compileStringAsync x 3 ops/sec @ 257ms/op ± 1.67% (min: 248ms, max: 267ms)
└─asyncCompiler.compileStringAsync x 51 ops/sec @ 19ms/op ± 174.40% (min: 2ms, max: 161ms)

Two takeaways:

  • There is no performance degradation on the top-level functions, they perform the same as in main
  • The new methods on the compiler classes are around 17 times faster 🎉

@jamesnw
Copy link
Contributor

jamesnw commented Nov 14, 2023

@jerivas One minor quibble is that the benchmarks exclude the compiler creation. I would expect that using the Compiler class for a single compile would be the same as not using the Compiler class. But this does show that subsequent compilations without the cost of creation are much faster indeed!

@jerivas jerivas changed the title Shared resources [Shared Resources] Embedded host implementation Nov 14, 2023
@jerivas
Copy link
Contributor Author

jerivas commented Nov 14, 2023

I would expect that using the Compiler class for a single compile would be the same as not using the Compiler class.

In the main implementation, the helper functions are spawning a new process on each call, which remains the same in this implementation. The benchmarks highlight the difference of not having to pay for that overhead over and over.

@jerivas jerivas marked this pull request as ready for review November 15, 2023 19:09
@jerivas jerivas requested a review from nex3 November 15, 2023 19:11
@nex3
Copy link
Contributor

nex3 commented Nov 16, 2023

@ntkme Can you take a quick look at this as well, since I believe you currently maintain the most widely-used embedded host with a long-lived compiler process?

lib/src/async-compiler.ts Outdated Show resolved Hide resolved
lib/src/async-compiler.ts Outdated Show resolved Hide resolved
lib/src/compiler.ts Outdated Show resolved Hide resolved
@ntkme
Copy link
Contributor

ntkme commented Jan 4, 2024

@jerivas One more change to fix the broken tests and I think we're good to go:

request.input = {case: 'path', value: path};

Relative path should always be resolved to absolute path on the host side before send to compiler. CWD can now change on host between different compilations with the same persisted compiler, but we don't have a way to change CWD of compiler once started or change the base dir for resolving relative path on compiler side, so host should resolve the relative path and only pass absolute path to compiler.

@jerivas
Copy link
Contributor Author

jerivas commented Jan 4, 2024

@ntkme @nex3 thanks for the review and the summary, I believe everything has been implemented now. I also added regression tests for the compilation ID and cwd issues

@jerivas jerivas requested review from ntkme and nex3 January 4, 2024 22:59
Copy link
Contributor

@ntkme ntkme left a comment

Choose a reason for hiding this comment

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

LGTM

/** The underlying process that's being wrapped. */
private readonly process = spawn(
compilerCommand[0],
[...compilerCommand.slice(1), '--embedded'],
{windowsHide: true}
{cwd: path.dirname(compilerCommand[0]), windowsHide: true}
Copy link
Contributor

Choose a reason for hiding this comment

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

Probably a good idea to add a comment here explaining why we set the CWD explicitly.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 66a7f66

(resolve, reject) =>
dispatcher.sendCompileRequest(request, (err, response) => {
this.compilations.delete(compilation);
if (this.compilations.size === 0) this.compilationId = 1;
Copy link
Contributor

Choose a reason for hiding this comment

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

Probably worth adding a comment here about avoiding unbounded growth as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 66a7f66

constructor(flag: Symbol | undefined) {
if (flag !== initFlag) {
throw utils.compilerError(
'AsyncCompiler can not be directly constructed. Please use `sass.initAsyncCompiler()` instead.'
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: long line

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 66a7f66

Copy link
Contributor

Choose a reason for hiding this comment

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

It's confusing to me that there are files named compile.ts and compiler.ts, but the latter doesn't define a class named Compiler or anything like that. I think these are shared functions between the two compiler classes, but that might be clearer in the directory structure if you put these in a compiler/ directory with sync.ts, async.ts, and utils.ts.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 2556525

lib/src/dispatcher.ts Outdated Show resolved Hide resolved
jerivas and others added 4 commits January 8, 2024 11:28
Co-authored-by: なつき <i@ntk.me>
* main:
  Update Dart Sass version and release
  Look for x64 version on arm64 windows (sass#266)
  Update Dart Sass version and release
  Support musl-libc and android (sass#265)
@ntkme
Copy link
Contributor

ntkme commented Jan 10, 2024

/**
* The next ID to use for a function. The embedded protocol requires that
* function IDs be globally unique.
*/
let nextFunctionID = 0;
/**
* Tracks functions that are defined on the host so that the compiler can
* execute them.
*/
export class FunctionRegistry<sync extends 'sync' | 'async'> {

We should also change FunctionRegistry's nextFunctionID from a global variable to a field. As it always constructs new FunctionRegistry for each compilation, this ID only need to be unique for the current compilation.

Note, for ImporterRegistry the importer ID is already a field.

export class ImporterRegistry<sync extends 'sync' | 'async'> {
/** Protocol buffer representations of the registered importers. */
readonly importers: proto.InboundMessage_CompileRequest_Importer[];
/** A map from importer IDs to their corresponding importers. */
private readonly importersById = new Map<number, Importer<sync>>();
/** A map from file importer IDs to their corresponding importers. */
private readonly fileImportersById = new Map<number, FileImporter<sync>>();
/** The next ID to use for an importer. */
private id = 0;

@jerivas
Copy link
Contributor Author

jerivas commented Jan 11, 2024

We should also change FunctionRegistry's nextFunctionID from a global variable to a field

@ntkme Thanks! Done in 84ae6a4

@jamesnw
Copy link
Contributor

jamesnw commented Jan 17, 2024

@jerivas Can you resolve the merge conflict?

@nex3 nex3 merged commit 91f382f into sass:main Jan 18, 2024
16 checks passed
@jgerigmeyer jgerigmeyer deleted the feature.shared-resources branch January 18, 2024 02:54
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.

5 participants