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

src,test: ensure that V8 fast APIs are called #54317

Merged
merged 3 commits into from
Aug 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions doc/contributing/adding-v8-fast-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ for example, they may not trigger garbage collection.
* To test fast APIs, make sure to run the tests in a loop with a decent
iterations count to trigger relevant optimizations that prefer the fast API
over the slow one.
* In debug mode (`--debug` or `--debug-node` flags), the fast API calls can be
tracked using the `TRACK_V8_FAST_API_CALL("key")` macro. This can be used to
count how many times fast paths are taken during tests. The key is a global
identifier and should be unique across the codebase.
Use `"binding_name.function_name"` or `"binding_name.function_name.suffix"` to
ensure uniqueness.
* The fast callback must be idempotent up to the point where error and fallback
conditions are checked, because otherwise executing the slow callback might
produce visible side effects twice.
Expand Down Expand Up @@ -77,6 +83,7 @@ A typical function that communicates between JavaScript and C++ is as follows.
* On the C++ side:

```cpp
#include "node_debug.h"
#include "v8-fast-api-calls.h"

namespace node {
Expand All @@ -102,9 +109,11 @@ A typical function that communicates between JavaScript and C++ is as follows.
const int32_t b,
v8::FastApiCallbackOptions& options) {
if (b == 0) {
TRACK_V8_FAST_API_CALL("custom_namespace.divide.error");
options.fallback = true;
return 0;
} else {
TRACK_V8_FAST_API_CALL("custom_namespace.divide.ok");
return a / b;
}
}
Expand Down Expand Up @@ -148,3 +157,42 @@ A typical function that communicates between JavaScript and C++ is as follows.
const int32_t b,
v8::FastApiCallbackOptions& options);
```

* In the unit tests:

Since the fast API function uses `TRACK_V8_FAST_API_CALL`, we can ensure that
the fast paths are taken and test them by writing tests that force
V8 optimizations and check the counters.

```js
// Flags: --expose-internals --no-warnings --allow-natives-syntax
'use strict';
const common = require('../common');

const { internalBinding } = require('internal/test/binding');
// We could also require a function that uses the internal binding internally.
const { divide } = internalBinding('custom_namespace');

if (common.isDebug) {
const { getV8FastApiCallCount } = internalBinding('debug');

// The function that will be optimized. It has to be a function written in
// JavaScript. Since `divide` comes from the C++ side, we need to wrap it.
function testFastPath(a, b) {
return divide(a, b);
}

eval('%PrepareFunctionForOptimization(testFastPath)');
// This call will let V8 know about the argument types that the function expects.
assert.strictEqual(testFastPath(6, 3), 2);

eval('%OptimizeFunctionOnNextCall(testFastPath)');
assert.strictEqual(testFastPath(8, 2), 4);
assert.throws(() => testFastPath(1, 0), {
code: 'ERR_INVALID_STATE',
});

assert.strictEqual(getV8FastApiCallCount('custom_namespace.divide.ok'), 1);
assert.strictEqual(getV8FastApiCallCount('custom_namespace.divide.error'), 1);
}
```
2 changes: 2 additions & 0 deletions node.gyp
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
'src/node_constants.cc',
'src/node_contextify.cc',
'src/node_credentials.cc',
'src/node_debug.cc',
'src/node_dir.cc',
'src/node_dotenv.cc',
'src/node_env_var.cc',
Expand Down Expand Up @@ -229,6 +230,7 @@
'src/node_constants.h',
'src/node_context_data.h',
'src/node_contextify.h',
'src/node_debug.h',
'src/node_dir.h',
'src/node_dotenv.h',
'src/node_errors.h',
Expand Down
5 changes: 4 additions & 1 deletion src/crypto/crypto_timing.cc
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
#include "crypto/crypto_timing.h"
#include "crypto/crypto_util.h"
#include "env-inl.h"
#include "node.h"
#include "node_debug.h"
#include "node_errors.h"
#include "v8.h"
#include "node.h"

#include <openssl/crypto.h>

Expand Down Expand Up @@ -57,10 +58,12 @@ bool FastTimingSafeEqual(Local<Value> receiver,
uint8_t* data_b;
if (a.length() != b.length() || !a.getStorageIfAligned(&data_a) ||
!b.getStorageIfAligned(&data_b)) {
TRACK_V8_FAST_API_CALL("crypto.timingSafeEqual.error");
options.fallback = true;
return false;
}

TRACK_V8_FAST_API_CALL("crypto.timingSafeEqual.ok");
return CRYPTO_memcmp(data_a, data_b, a.length()) == 0;
}

Expand Down
7 changes: 7 additions & 0 deletions src/node_binding.cc
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@
#define NODE_BUILTIN_PROFILER_BINDINGS(V)
#endif

#ifdef DEBUG
#define NODE_BUILTIN_DEBUG_BINDINGS(V) V(debug)
#else
#define NODE_BUILTIN_DEBUG_BINDINGS(V)
#endif

// A list of built-in bindings. In order to do binding registration
// in node::Init(), need to add built-in bindings in the following list.
// Then in binding::RegisterBuiltinBindings(), it calls bindings' registration
Expand Down Expand Up @@ -96,6 +102,7 @@
NODE_BUILTIN_OPENSSL_BINDINGS(V) \
NODE_BUILTIN_ICU_BINDINGS(V) \
NODE_BUILTIN_PROFILER_BINDINGS(V) \
NODE_BUILTIN_DEBUG_BINDINGS(V) \
NODE_BUILTIN_QUIC_BINDINGS(V)

// This is used to load built-in bindings. Instead of using
Expand Down
101 changes: 101 additions & 0 deletions src/node_debug.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#include "node_debug.h"

#ifdef DEBUG
#include "node_binding.h"

#include "env-inl.h"
#include "util.h"
#include "v8-fast-api-calls.h"
#include "v8.h"

#include <string_view>
#include <unordered_map>
#endif // DEBUG

namespace node {
namespace debug {

#ifdef DEBUG
using v8::Context;
using v8::FastApiCallbackOptions;
using v8::FunctionCallbackInfo;
using v8::Local;
using v8::Number;
using v8::Object;
using v8::Value;

thread_local std::unordered_map<std::string_view, int> v8_fast_api_call_counts;

void TrackV8FastApiCall(std::string_view key) {
v8_fast_api_call_counts[key]++;
Copy link
Member

@joyeecheung joyeecheung Aug 12, 2024

Choose a reason for hiding this comment

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

For the purpose of the tests, I think using a bool should be enough - an int would overflow if the process runs for long enough.

Also if we require the counter/toggle to be pre-declared in a macro list, we can just track them in thread_local bool instead of doing a runtime map lookup, and the overhead of simply setting a variable to true feels negligible enough to be enabled at runtime.

Copy link
Member Author

Choose a reason for hiding this comment

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

an int would overflow if the process runs for long enough

I know that but I don't think it matters. As an internal debug-only API, it is not meant to be useful outside of our unit tests and those would never run for long enough.

I thought a counter could be useful for complex cases where we want to call the same API multiple times (or it's called in a loop internally).

I'd say feel free to refactor / change in a future PR (or block this one). I spent enough energy on it myself.

Copy link
Member

Choose a reason for hiding this comment

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

I thought a counter could be useful for complex cases where we want to call the same API multiple times (or it's called in a loop internally).

Even if it's called multiple times, only V8 can really reliably check exactly how many times the fast API is run, as that's subject to the optimizing strategy - unless we use the intrinsics that are not supported for embedders' use cases. As an embedder a true/false is the best we can count on.

I'd say feel free to refactor / change in a future PR (or block this one). I spent enough energy on it myself.

Sorry if this is untimely - feel free to land it, just trying to address the previous comments about release/debug differences.

}

int GetV8FastApiCallCount(std::string_view key) {
return v8_fast_api_call_counts[key];
}

void GetV8FastApiCallCount(const FunctionCallbackInfo<Value>& args) {
Environment* env = Environment::GetCurrent(args);
if (!args[0]->IsString()) {
env->ThrowError("getV8FastApiCallCount must be called with a string");
return;
}
Utf8Value utf8_key(env->isolate(), args[0]);
args.GetReturnValue().Set(GetV8FastApiCallCount(utf8_key.ToString()));
}

void SlowIsEven(const FunctionCallbackInfo<Value>& args) {
Environment* env = Environment::GetCurrent(args);
if (!args[0]->IsNumber()) {
env->ThrowError("isEven must be called with a number");
return;
}
int64_t value = args[0].As<Number>()->Value();
args.GetReturnValue().Set(value % 2 == 0);
}

bool FastIsEven(Local<Value> receiver,
const int64_t value,
// NOLINTNEXTLINE(runtime/references)
FastApiCallbackOptions& options) {
TRACK_V8_FAST_API_CALL("debug.isEven");
return value % 2 == 0;
}

void SlowIsOdd(const FunctionCallbackInfo<Value>& args) {
Environment* env = Environment::GetCurrent(args);
if (!args[0]->IsNumber()) {
env->ThrowError("isOdd must be called with a number");
return;
}
int64_t value = args[0].As<Number>()->Value();
args.GetReturnValue().Set(value % 2 != 0);
}

bool FastIsOdd(Local<Value> receiver,
const int64_t value,
// NOLINTNEXTLINE(runtime/references)
FastApiCallbackOptions& options) {
TRACK_V8_FAST_API_CALL("debug.isOdd");
return value % 2 != 0;
}

static v8::CFunction fast_is_even(v8::CFunction::Make(FastIsEven));
static v8::CFunction fast_is_odd(v8::CFunction::Make(FastIsOdd));

void Initialize(Local<Object> target,
Local<Value> unused,
Local<Context> context,
void* priv) {
SetMethod(context, target, "getV8FastApiCallCount", GetV8FastApiCallCount);
SetFastMethod(context, target, "isEven", SlowIsEven, &fast_is_even);
SetFastMethod(context, target, "isOdd", SlowIsOdd, &fast_is_odd);
}
#endif // DEBUG

} // namespace debug
} // namespace node

#ifdef DEBUG
targos marked this conversation as resolved.
Show resolved Hide resolved
NODE_BINDING_CONTEXT_AWARE_INTERNAL(debug, node::debug::Initialize)
#endif // DEBUG
24 changes: 24 additions & 0 deletions src/node_debug.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#pragma once

#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS

#ifdef DEBUG
#include <string_view>
#endif // DEBUG

namespace node {
namespace debug {

#ifdef DEBUG
void TrackV8FastApiCall(std::string_view key);
int GetV8FastApiCallCount(std::string_view key);

#define TRACK_V8_FAST_API_CALL(key) node::debug::TrackV8FastApiCall(key)
#else // !DEBUG
#define TRACK_V8_FAST_API_CALL(key)
#endif // DEBUG

} // namespace debug
} // namespace node

#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS
2 changes: 1 addition & 1 deletion test/benchmark/test-benchmark-napi.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ if (!common.isMainThread) {
common.skip('addons are not supported in workers');
}

if (process.features.debug) {
if (common.isDebug) {
common.skip('benchmark does not work with debug build yet');
}
const runBenchmark = require('../common/benchmark');
Expand Down
4 changes: 3 additions & 1 deletion test/common/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ const isOpenBSD = process.platform === 'openbsd';
const isLinux = process.platform === 'linux';
const isMacOS = process.platform === 'darwin';
const isASan = process.config.variables.asan === 1;
const isDebug = process.features.debug;
const isPi = (() => {
try {
// Normal Raspberry Pi detection is to find the `Raspberry Pi` string in
Expand Down Expand Up @@ -280,7 +281,7 @@ function platformTimeout(ms) {
const multipliers = typeof ms === 'bigint' ?
{ two: 2n, four: 4n, seven: 7n } : { two: 2, four: 4, seven: 7 };

if (process.features.debug)
if (isDebug)
ms = multipliers.two * ms;

if (exports.isAIX || exports.isIBMi)
Expand Down Expand Up @@ -998,6 +999,7 @@ const common = {
invalidArgTypeHelper,
isAlive,
isASan,
isDebug,
isDumbTerminal,
isFreeBSD,
isLinux,
Expand Down
59 changes: 59 additions & 0 deletions test/parallel/test-debug-v8-fast-api.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Flags: --expose-internals --no-warnings --allow-natives-syntax
'use strict';
const common = require('../common');

const assert = require('assert');
const { internalBinding } = require('internal/test/binding');

if (!common.isDebug) {
targos marked this conversation as resolved.
Show resolved Hide resolved
assert.throws(() => internalBinding('debug'), {
message: 'No such binding: debug'
});
return;
}

const {
getV8FastApiCallCount,
isEven,
isOdd,
} = internalBinding('debug');

assert.throws(() => getV8FastApiCallCount(), {
message: 'getV8FastApiCallCount must be called with a string',
});

function testIsEven() {
for (let i = 0; i < 10; i++) {
assert.strictEqual(isEven(i), i % 2 === 0);
}
}

function testIsOdd() {
for (let i = 0; i < 20; i++) {
assert.strictEqual(isOdd(i), i % 2 !== 0);
}
}

// Should return 0 by default for any string.
assert.strictEqual(getV8FastApiCallCount(''), 0);
assert.strictEqual(getV8FastApiCallCount('foo'), 0);
assert.strictEqual(getV8FastApiCallCount('debug.isEven'), 0);
assert.strictEqual(getV8FastApiCallCount('debug.isOdd'), 0);

eval('%PrepareFunctionForOptimization(testIsEven)');
testIsEven();
eval('%PrepareFunctionForOptimization(testIsOdd)');
testIsOdd();

// Functions should not be optimized yet.
assert.strictEqual(getV8FastApiCallCount('debug.isEven'), 0);
assert.strictEqual(getV8FastApiCallCount('debug.isOdd'), 0);

eval('%OptimizeFunctionOnNextCall(testIsEven)');
testIsEven();
eval('%OptimizeFunctionOnNextCall(testIsOdd)');
testIsOdd();

// Functions should have been optimized and fast path taken.
assert.strictEqual(getV8FastApiCallCount('debug.isEven'), 10);
assert.strictEqual(getV8FastApiCallCount('debug.isOdd'), 20);
23 changes: 23 additions & 0 deletions test/sequential/test-crypto-timing-safe-equal.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// Flags: --expose-internals --no-warnings --allow-natives-syntax
'use strict';
const common = require('../common');
if (!common.hasCrypto)
Expand Down Expand Up @@ -91,3 +92,25 @@ assert.throws(
name: 'TypeError',
}
);

if (common.isDebug) {
const { internalBinding } = require('internal/test/binding');
const { getV8FastApiCallCount } = internalBinding('debug');

const foo = Buffer.from('foo');
const bar = Buffer.from('bar');
const longer = Buffer.from('longer');
function testFastPath(buf1, buf2) {
return crypto.timingSafeEqual(buf1, buf2);
}
eval('%PrepareFunctionForOptimization(testFastPath)');
assert.strictEqual(testFastPath(foo, bar), false);
eval('%OptimizeFunctionOnNextCall(testFastPath)');
assert.strictEqual(testFastPath(foo, bar), false);
assert.strictEqual(testFastPath(foo, foo), true);
assert.throws(() => testFastPath(foo, longer), {
code: 'ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH',
});
assert.strictEqual(getV8FastApiCallCount('crypto.timingSafeEqual.ok'), 2);
assert.strictEqual(getV8FastApiCallCount('crypto.timingSafeEqual.error'), 1);
}
Loading
Loading