src,test: ensure that V8 fast APIs are called

Adds a debug-only macro that can be used to track when a V8 fast API is
called. A map of counters is maintained in in thread-local storage and
an internal API can be called to get the total count associated with
a call id.
Specific tests are added and `crypto.timingSafeEqual` as well as
internal documentation are updated to show how to use the macro
and test fast API calls without running long loops.

PR-URL: https://github.com/nodejs/node/pull/54317
Reviewed-By: Robert Nagy <ronagy@icloud.com>
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com>
Reviewed-By: Tobias Nießen <tniessen@tnie.de>
This commit is contained in:
Michaël Zasso 2024-08-13 14:37:02 +02:00 committed by GitHub
parent 9e8cc2933e
commit 1d35a066e7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 284 additions and 3 deletions

View File

@ -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 * 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 iterations count to trigger relevant optimizations that prefer the fast API
over the slow one. 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 * The fast callback must be idempotent up to the point where error and fallback
conditions are checked, because otherwise executing the slow callback might conditions are checked, because otherwise executing the slow callback might
produce visible side effects twice. produce visible side effects twice.
@ -77,6 +83,7 @@ A typical function that communicates between JavaScript and C++ is as follows.
* On the C++ side: * On the C++ side:
```cpp ```cpp
#include "node_debug.h"
#include "v8-fast-api-calls.h" #include "v8-fast-api-calls.h"
namespace node { namespace node {
@ -102,9 +109,11 @@ A typical function that communicates between JavaScript and C++ is as follows.
const int32_t b, const int32_t b,
v8::FastApiCallbackOptions& options) { v8::FastApiCallbackOptions& options) {
if (b == 0) { if (b == 0) {
TRACK_V8_FAST_API_CALL("custom_namespace.divide.error");
options.fallback = true; options.fallback = true;
return 0; return 0;
} else { } else {
TRACK_V8_FAST_API_CALL("custom_namespace.divide.ok");
return a / b; return a / b;
} }
} }
@ -148,3 +157,42 @@ A typical function that communicates between JavaScript and C++ is as follows.
const int32_t b, const int32_t b,
v8::FastApiCallbackOptions& options); 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);
}
```

View File

@ -107,6 +107,7 @@
'src/node_constants.cc', 'src/node_constants.cc',
'src/node_contextify.cc', 'src/node_contextify.cc',
'src/node_credentials.cc', 'src/node_credentials.cc',
'src/node_debug.cc',
'src/node_dir.cc', 'src/node_dir.cc',
'src/node_dotenv.cc', 'src/node_dotenv.cc',
'src/node_env_var.cc', 'src/node_env_var.cc',
@ -229,6 +230,7 @@
'src/node_constants.h', 'src/node_constants.h',
'src/node_context_data.h', 'src/node_context_data.h',
'src/node_contextify.h', 'src/node_contextify.h',
'src/node_debug.h',
'src/node_dir.h', 'src/node_dir.h',
'src/node_dotenv.h', 'src/node_dotenv.h',
'src/node_errors.h', 'src/node_errors.h',

View File

@ -1,9 +1,10 @@
#include "crypto/crypto_timing.h" #include "crypto/crypto_timing.h"
#include "crypto/crypto_util.h" #include "crypto/crypto_util.h"
#include "env-inl.h" #include "env-inl.h"
#include "node.h"
#include "node_debug.h"
#include "node_errors.h" #include "node_errors.h"
#include "v8.h" #include "v8.h"
#include "node.h"
#include <openssl/crypto.h> #include <openssl/crypto.h>
@ -57,10 +58,12 @@ bool FastTimingSafeEqual(Local<Value> receiver,
uint8_t* data_b; uint8_t* data_b;
if (a.length() != b.length() || !a.getStorageIfAligned(&data_a) || if (a.length() != b.length() || !a.getStorageIfAligned(&data_a) ||
!b.getStorageIfAligned(&data_b)) { !b.getStorageIfAligned(&data_b)) {
TRACK_V8_FAST_API_CALL("crypto.timingSafeEqual.error");
options.fallback = true; options.fallback = true;
return false; return false;
} }
TRACK_V8_FAST_API_CALL("crypto.timingSafeEqual.ok");
return CRYPTO_memcmp(data_a, data_b, a.length()) == 0; return CRYPTO_memcmp(data_a, data_b, a.length()) == 0;
} }

View File

@ -20,6 +20,12 @@
#define NODE_BUILTIN_PROFILER_BINDINGS(V) #define NODE_BUILTIN_PROFILER_BINDINGS(V)
#endif #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 // 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. // in node::Init(), need to add built-in bindings in the following list.
// Then in binding::RegisterBuiltinBindings(), it calls bindings' registration // Then in binding::RegisterBuiltinBindings(), it calls bindings' registration
@ -96,6 +102,7 @@
NODE_BUILTIN_OPENSSL_BINDINGS(V) \ NODE_BUILTIN_OPENSSL_BINDINGS(V) \
NODE_BUILTIN_ICU_BINDINGS(V) \ NODE_BUILTIN_ICU_BINDINGS(V) \
NODE_BUILTIN_PROFILER_BINDINGS(V) \ NODE_BUILTIN_PROFILER_BINDINGS(V) \
NODE_BUILTIN_DEBUG_BINDINGS(V) \
NODE_BUILTIN_QUIC_BINDINGS(V) NODE_BUILTIN_QUIC_BINDINGS(V)
// This is used to load built-in bindings. Instead of using // This is used to load built-in bindings. Instead of using

101
src/node_debug.cc Normal file
View File

@ -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]++;
}
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
NODE_BINDING_CONTEXT_AWARE_INTERNAL(debug, node::debug::Initialize)
#endif // DEBUG

24
src/node_debug.h Normal file
View File

@ -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

View File

@ -10,7 +10,7 @@ if (!common.isMainThread) {
common.skip('addons are not supported in workers'); 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'); common.skip('benchmark does not work with debug build yet');
} }
const runBenchmark = require('../common/benchmark'); const runBenchmark = require('../common/benchmark');

View File

@ -143,6 +143,7 @@ const isOpenBSD = process.platform === 'openbsd';
const isLinux = process.platform === 'linux'; const isLinux = process.platform === 'linux';
const isMacOS = process.platform === 'darwin'; const isMacOS = process.platform === 'darwin';
const isASan = process.config.variables.asan === 1; const isASan = process.config.variables.asan === 1;
const isDebug = process.features.debug;
const isPi = (() => { const isPi = (() => {
try { try {
// Normal Raspberry Pi detection is to find the `Raspberry Pi` string in // Normal Raspberry Pi detection is to find the `Raspberry Pi` string in
@ -280,7 +281,7 @@ function platformTimeout(ms) {
const multipliers = typeof ms === 'bigint' ? const multipliers = typeof ms === 'bigint' ?
{ two: 2n, four: 4n, seven: 7n } : { two: 2, four: 4, seven: 7 }; { two: 2n, four: 4n, seven: 7n } : { two: 2, four: 4, seven: 7 };
if (process.features.debug) if (isDebug)
ms = multipliers.two * ms; ms = multipliers.two * ms;
if (exports.isAIX || exports.isIBMi) if (exports.isAIX || exports.isIBMi)
@ -998,6 +999,7 @@ const common = {
invalidArgTypeHelper, invalidArgTypeHelper,
isAlive, isAlive,
isASan, isASan,
isDebug,
isDumbTerminal, isDumbTerminal,
isFreeBSD, isFreeBSD,
isLinux, isLinux,

View File

@ -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) {
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);

View File

@ -1,3 +1,4 @@
// Flags: --expose-internals --no-warnings --allow-natives-syntax
'use strict'; 'use strict';
const common = require('../common'); const common = require('../common');
if (!common.hasCrypto) if (!common.hasCrypto)
@ -91,3 +92,25 @@ assert.throws(
name: 'TypeError', 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);
}

View File

@ -2,6 +2,7 @@ import { AsyncWrapBinding } from './internalBinding/async_wrap';
import { BlobBinding } from './internalBinding/blob'; import { BlobBinding } from './internalBinding/blob';
import { ConfigBinding } from './internalBinding/config'; import { ConfigBinding } from './internalBinding/config';
import { ConstantsBinding } from './internalBinding/constants'; import { ConstantsBinding } from './internalBinding/constants';
import { DebugBinding } from './internalBinding/debug';
import { HttpParserBinding } from './internalBinding/http_parser'; import { HttpParserBinding } from './internalBinding/http_parser';
import { FsBinding } from './internalBinding/fs'; import { FsBinding } from './internalBinding/fs';
import { FsDirBinding } from './internalBinding/fs_dir'; import { FsDirBinding } from './internalBinding/fs_dir';
@ -35,6 +36,7 @@ interface InternalBindingMap {
blob: BlobBinding; blob: BlobBinding;
config: ConfigBinding; config: ConfigBinding;
constants: ConstantsBinding; constants: ConstantsBinding;
debug: DebugBinding;
fs: FsBinding; fs: FsBinding;
fs_dir: FsDirBinding; fs_dir: FsDirBinding;
http_parser: HttpParserBinding; http_parser: HttpParserBinding;

10
typings/internalBinding/debug.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
/**
* The `internalBinding('debug')` binding provides access to internal debugging
* utilities. They are only available when Node.js is built with the `--debug`
* or `--debug-node` compile-time flags.
*/
export interface DebugBinding {
getV8FastApiCallCount(name: string): number;
isEven(value: number): boolean;
isOdd(value: number): boolean;
}