diff --git a/src/bun.js/bindings/generated_classes_list.zig b/src/bun.js/bindings/generated_classes_list.zig index 28595f2675..ea40c6e65f 100644 --- a/src/bun.js/bindings/generated_classes_list.zig +++ b/src/bun.js/bindings/generated_classes_list.zig @@ -73,4 +73,5 @@ pub const Classes = struct { pub const BytesInternalReadableStreamSource = JSC.WebCore.ByteStream.Source; pub const BrotliEncoder = JSC.API.BrotliEncoder; pub const BrotliDecoder = JSC.API.BrotliDecoder; + pub const RecordableHistogram = JSC.Node.RecordableHistogram; // for new class, be sure to add here }; diff --git a/src/bun.js/bindings/webcore/JSPerformance.cpp b/src/bun.js/bindings/webcore/JSPerformance.cpp index 43fbe9294e..afb6c42957 100644 --- a/src/bun.js/bindings/webcore/JSPerformance.cpp +++ b/src/bun.js/bindings/webcore/JSPerformance.cpp @@ -209,7 +209,10 @@ static const HashTableValue JSPerformancePrototypeTableValues[] = { // { "clearResourceTimings"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsPerformancePrototypeFunction_clearResourceTimings, 0 } }, // { "setResourceTimingBufferSize"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsPerformancePrototypeFunction_setResourceTimingBufferSize, 1 } }, { "mark"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsPerformancePrototypeFunction_mark, 1 } }, - { "timerify"_s, static_cast(JSC::PropertyAttribute::Builtin), NoIntrinsic, { HashTableValue::BuiltinGeneratorType, performanceTimerifyCodeGenerator, 1 } }, + + { "timerify"_s, static_cast(JSC::PropertyAttribute::Builtin), NoIntrinsic, { HashTableValue::BuiltinGeneratorType, performanceTimerifyCodeGenerator, 2 } }, // this routes through codegen'ed CPP to timerify in Performance.ts + // ERIK note because performanceTimerifyCodeGenerator has "CodeGenerator" at the end, we know the C++ bindings are generated for it based on the existance of "Performance.ts" in src/js/builtins "export timerify" + { "clearMarks"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsPerformancePrototypeFunction_clearMarks, 0 } }, { "measure"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsPerformancePrototypeFunction_measure, 1 } }, { "clearMeasures"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsPerformancePrototypeFunction_clearMeasures, 0 } }, diff --git a/src/bun.js/node/node.classes.ts b/src/bun.js/node/node.classes.ts index c252f72954..fd6af35797 100644 --- a/src/bun.js/node/node.classes.ts +++ b/src/bun.js/node/node.classes.ts @@ -549,4 +549,36 @@ export default [ // createWriteStream: { fn: "createWriteStream", length: 2 }, }, }), + define({ + name: "RecordableHistogram", + construct: false, + noConstructor: true, + finalize: true, // this triggers the deallocation for bun.destroy + configurable: false, + hasPendingActivity: false, + klass: {}, + JSType: "0b11101110", + proto: { + min: { getter: "min" }, + max: { getter: "max" }, + mean: { getter: "mean" }, + exceeds: { getter: "exceeds" }, + stddev: { getter: "stddev" }, + count: { getter: "count" }, + percentiles: { getter: "percentiles" }, + reset: { fn: "reset", length: 0 }, + record: { fn: "record", length: 1 }, + recordDelta: { fn: "recordDelta", length: 0 }, + add: { fn: "add", length: 1 }, + percentile: { fn: "percentile", length: 1 }, + minBigInt: { fn: "minBigInt", length: 0 }, + maxBigInt: { fn: "maxBigInt", length: 0 }, + exceedsBigInt: { fn: "exceedsBigInt", length: 0 }, + countBigInt: { fn: "countBigInt", length: 0 }, + percentilesBigInt: { fn: "percentilesBigInt", length: 0 }, + percentileBigInt: { fn: "percentileBigInt", length: 1 }, + toJSON: { fn: "toJSON", length: 0 }, + }, + values: [], + }), ]; diff --git a/src/bun.js/node/node_perf_hooks_histogram_binding.zig b/src/bun.js/node/node_perf_hooks_histogram_binding.zig new file mode 100644 index 0000000000..c42e034730 --- /dev/null +++ b/src/bun.js/node/node_perf_hooks_histogram_binding.zig @@ -0,0 +1,181 @@ +const std = @import("std"); +const bun = @import("root").bun; +const meta = bun.meta; +const JSC = bun.JSC; +const JSValue = JSC.JSValue; + +pub const RecordableHistogram = struct { + pub usingnamespace JSC.Codegen.JSRecordableHistogram; + + min: u64 = 1, + max: u64 = 2, + mean: f64 = 3, // todo: make this optional + exceeds: u64 = 4, + stddev: f64 = 5, // todo: make this optional + count: u64 = 6, + percentilesInternal: std.AutoHashMap(f32, f32) = std.AutoHashMap(f32, f32).init(bun.default_allocator), + + const This = @This(); + + //todo: these should also be explicit functions, IE both .max and .max() work + pub const min = getter(.min); + pub const max = getter(.max); + pub const mean = getter(.mean); + pub const exceeds = getter(.exceeds); + pub const stddev = getter(.stddev); + pub const count = getter(.count); + + // we need a special getter for percentiles because it's a hashmap + pub const percentiles = @as( + PropertyGetter, + struct { + pub fn callback(this: *This, globalThis: *JSC.JSGlobalObject) callconv(.C) JSValue { + _ = this; + _ = globalThis; + return .undefined; + } + }.callback, + ); + + pub fn record(this: *This, globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) callconv(.C) JSValue { + _ = this; + _ = globalThis; + _ = callframe; + return .undefined; + } + + pub fn recordDelta(this: *This, globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) callconv(.C) JSValue { + _ = this; + _ = globalThis; + _ = callframe; + return .undefined; + } + + pub fn add(this: *This, globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) callconv(.C) JSValue { + _ = this; + const args = callframe.arguments(1).slice(); + if (args.len != 1) { + globalThis.throwInvalidArguments("Expected 1 argument", .{}); + return .zero; + } + // todo: make below work + // const other = args[0].to(RecordableHistogram); + // _ = other; + return .undefined; + } + + pub fn reset(this: *This, globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) callconv(.C) JSValue { + _ = this; + _ = globalThis; + _ = callframe; + return .undefined; + } + + pub fn countBigInt(this: *This, globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) callconv(.C) JSValue { + _ = this; + _ = globalThis; + _ = callframe; + return .undefined; + } + + pub fn minBigInt(this: *This, globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) callconv(.C) JSValue { + _ = this; + _ = globalThis; + _ = callframe; + return .undefined; + } + + pub fn maxBigInt(this: *This, globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) callconv(.C) JSValue { + _ = this; + _ = globalThis; + _ = callframe; + return .undefined; + } + + pub fn exceedsBigInt(this: *This, globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) callconv(.C) JSValue { + _ = this; + _ = globalThis; + _ = callframe; + return .undefined; + } + + pub fn percentile(this: *This, globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) callconv(.C) JSValue { + _ = this; + const args = callframe.arguments(1).slice(); + if (args.len != 1) { + globalThis.throwInvalidArguments("Expected 1 argument", .{}); + return .zero; + } + // todo: make below work + // const percent = args[0].to(f64); + // _ = percent; + return .undefined; + } + + pub fn percentileBigInt(this: *This, globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) callconv(.C) JSValue { + _ = this; + const args = callframe.arguments(1).slice(); + if (args.len != 1) { + globalThis.throwInvalidArguments("Expected 1 argument", .{}); + return .zero; + } + // todo: make below work + // const percent = args[0].to(f64); + // _ = percent; + return .undefined; + } + + pub fn percentilesBigInt(this: *This, globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) callconv(.C) JSValue { + _ = this; + _ = globalThis; + _ = callframe; + return .undefined; + } + + pub fn toJSON(this: *This, globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) callconv(.C) JSValue { + _ = this; + _ = globalThis; + _ = callframe; + return .undefined; + } + + const PropertyGetter = fn (this: *This, globalObject: *JSC.JSGlobalObject) callconv(.C) JSC.JSValue; + fn getter(comptime field: meta.FieldEnum(This)) PropertyGetter { + return struct { + pub fn callback(this: *This, globalObject: *JSC.JSGlobalObject) callconv(.C) JSC.JSValue { + const v = @field(this, @tagName(field)); + return globalObject.toJS(v, .temporary); + } + }.callback; + } + + pub const value = getter(.value); + + // since we create this with bun.new, we need to have it be destroyable + // our node.classes.ts has finalize=true to generate the call to finalize + pub fn finalize(this: *This) callconv(.C) void { + this.percentilesInternal.deinit(); + bun.destroy(this); + } +}; + +fn createHistogram(globalThis: *JSC.JSGlobalObject, _: *JSC.CallFrame) callconv(.C) JSC.JSValue { + return bun.new(RecordableHistogram, .{}).toJS(globalThis); +} + +pub fn createPerfHooksHistogramBinding(global: *JSC.JSGlobalObject) callconv(.C) JSC.JSValue { + const histogram = JSC.JSValue.createEmptyObject(global, 1); + histogram.put( + global, + bun.String.init("createHistogram"), + JSC.JSFunction.create( + global, + "createHistogram", + &createHistogram, + 3, // function length + .{}, + ), + ); + + return histogram; +} diff --git a/src/erik.js b/src/erik.js index a708f28a20..2e00e09ca9 100644 --- a/src/erik.js +++ b/src/erik.js @@ -1,8 +1,10 @@ -import { performance } from "node:perf_hooks"; +// import { performance, createHistogram } from "node:perf_hooks"; +// cannot use import statement outside a module, so we do: +const { performance, createHistogram } = require("perf_hooks"); const fn = () => { console.log("this is the function that will be timed"); }; -let wrapped = performance.timerify(fn); -wrapped(); +const histogram = createHistogram(); +histogram.update(2); diff --git a/src/js/builtins/Performance.ts b/src/js/builtins/Performance.ts index 9865fddf1c..72cdda3ce5 100644 --- a/src/js/builtins/Performance.ts +++ b/src/js/builtins/Performance.ts @@ -1,10 +1,23 @@ -export function timerify(fn: Function) { +export function timerify(fn: Function, options) { + const { histogram } = options; + + // create histogram class + class Histogram { + record(duration: number) { + console.log(`Recording duration: ${duration}`); + } + } + // wrap fn in a timer and return the wrapped function return function (...args: any[]) { const start = performance.now(); const result = fn(...args); const end = performance.now(); - console.log(`Function took ${end - start}ms`); + + if (histogram) { + console.log("recorded"); + histogram.record(Math.ceil((end - start) * 1e6)); + } return result; }; } diff --git a/src/js/node/perf_hooks.ts b/src/js/node/perf_hooks.ts index 9d3bd84c8a..6912dbb531 100644 --- a/src/js/node/perf_hooks.ts +++ b/src/js/node/perf_hooks.ts @@ -98,6 +98,10 @@ Object.setPrototypeOf(PerformanceResourceTiming, PerformanceEntry); export default { performance: { + // perf_hooks is a builtin global, so JSC is aware of it through C++ bindings + // see JSPerformance.cpp HashTableValue for more details + // perf_hooks has a performance module with these exported functions, + // most implemented in C++ but I decide to do timerify in JS mark(f) { return performance.mark(...arguments); }, @@ -105,7 +109,9 @@ export default { return performance.measure(...arguments); }, timerify(f) { - return performance.timerify(...arguments); + // in this case, we want it to go back to JS function since we're passing a JS function + // go to JSPerformance.cpp HashTableValue to see how this is handled differently from the rest + return performance.timerify(...arguments); // this routes to JSPerformance.cpp }, clearMarks(f) { return performance.clearMarks(...arguments); @@ -162,7 +168,8 @@ export default { throwNotImplemented("perf_hooks.monitorEventLoopDelay"); }, createHistogram() { - throwNotImplemented("perf_hooks.createHistogram"); + const { createHistogram } = $zig("node_perf_hooks_histogram_binding.zig", "createPerfHooksHistogramBinding"); + return createHistogram(); }, PerformanceResourceTiming, }; diff --git a/src/jsc.zig b/src/jsc.zig index 872a47e5f6..008722c182 100644 --- a/src/jsc.zig +++ b/src/jsc.zig @@ -61,6 +61,7 @@ pub const Node = struct { pub usingnamespace @import("./bun.js/node/node_fs_stat_watcher.zig"); pub usingnamespace @import("./bun.js/node/node_fs_binding.zig"); pub usingnamespace @import("./bun.js/node/node_os.zig"); + pub usingnamespace @import("./bun.js/node/node_perf_hooks_histogram_binding.zig"); pub const fs = @import("./bun.js/node/node_fs_constant.zig"); pub const Util = struct { pub const parseArgs = @import("./bun.js/node/util/parse_args.zig").parseArgs; diff --git a/src/notes.md b/src/notes.md new file mode 100644 index 0000000000..b996699b2a --- /dev/null +++ b/src/notes.md @@ -0,0 +1,176 @@ +# performance.timerify project + +goal: implement timerify: +timerify(fn, {histogram: Histogram}) + +timerify modifies the passed-in histogram by registering events into it. The histogram can later print performance distribution after many runs. + +Two main parts of this so far: + +- implement timerify function in JS (because it wraps a user's function and that's annoying to pass across language boundaries) +- implement Histogram class in Zig (used by timerify but nice to have performance and it's in CPP in node) + +## How I got timerify to work + +Thanks Jarred for the help on this one. + +performance.timerify is part of the `node:perf_hooks` global, so importing it takes this code path: + +- `perf_hooks.ts` + - this file was already here + - note we get "Performance" from globalThis, so that's proof it's a global + - `performance.timerify` calls via `JSPerformance.cpp` +- `JSPerformance.cpp` + - this file was already here + - name prefix "JS" implies codegen but this case is exception since it's part of webcore which we'd copied in and now manually edit + - note the `JSPerformancePrototypeTableValues`: + - for the other performance functions, they're implemented in C++ so we use `JSC::PropertyAttribute::Function` and `HashTableValue::NativeFunctionType` which call their respective C++ implementations + - for timerify, we use `PropertyAttribute::Builtin` and `HashTableValue::BuiltinGeneratorType` which will call through an _autogenerated_ C++ file to arrive back at a JS/TS file. It uses the `performanceTimerifyCodeGenerator` identifier, which I'll explain below. +- `Performance.ts` + - I created this file + - the existance of this TS file in `src/js/builtins` means it'll receive codegen for any exported functions + - we `export timerify` + - codegen will generate this glue function: (camelCased) {filename}{functionName}CodeGenerator, which is why we used `performanceTimerifyCodeGenerator` in `JSPerformance.cpp` + +Calling `timerify` from JS will just feel like you're calling `Performance.ts``timerify` directly. It "just works" as if the glue code weren't there. + +## how I got Histogram to work + +This one was a bit harder, because I implement the class in Zig. + +I followed `node_fs.zig` for inspiration. Thanks Dave as well for walking me through initial setup. + +importing Histogram from node:perf_hooks follows this code path: + +- `perf_hooks.ts` + - note the key bit here: `$zig("node_perf_hooks_histogram_binding.zig", "createPerfHooksHistogramBinding");` + - `$zig()` calls into a zig binding + - `node_perf_hooks_histogram_binding.zig` is in `src/bun.js/node` + - `createPerfHooksHistogramBinding` is the function name +- `node_perf_hooks_histogram_binding.zig` + - I made this file + - let's look at `createPerfHooksHistogramBinding`: + +```javascript +pub fn createPerfHooksHistogramBinding(global: *JSC.JSGlobalObject) callconv(.C) JSC.JSValue { + const histogram = JSC.JSValue.createEmptyObject(global, 1); + histogram.put( + global, + bun.String.init("createHistogram"), + JSC.JSFunction.create( + global, + "createHistogram", + &createHistogram, + 3, // function length + .{}, + ), + ); + + return histogram; +} +``` + +- it must be pub fn +- it must receive `(global: *JSC.JSGlobalObject)` +- it must use `callconv(.C)` since it needs to speak C ABI for JS interop +- it must return a `JSC.JSValue` +- we want to create an object in JSC, which is the `JSC.JSValue` that's passed back to `perf_hooks.ts` +- we add the `createHistogram` as a function onto it. This is the name JS sees. The `3` is the max quantity of args. + +- let's look at `createHistogram` itself, which is the function actually called by JS. + +```javascript + fn createHistogram(globalThis: *JSC.JSGlobalObject, _: *JSC.CallFrame) callconv(.C) JSC.JSValue { + return bun.new(Histogram, .{}).toJS(globalThis); +} +``` + +- `bun.new(T, init_value)` creates the object on heap, and `.toJS(globalThis)` makes it a `JSC.JSValue` for return + - you'll see later where the destroy happens. +- following this pattern, we can have an arbitrary Histogram struct, fully in Zig. + +But there's more to do. We have the createHistogram function but we need to codegen a way to represent the Histogram class itself and pass it across Zig/JS boundaries. + +- `src/bun.js/node/node.classes.ts` + - this file defines classes that need to be codegen'ed + - I add the histogram class, important fields have comments: + +```javascript +define({ + name: "Histogram", // the name in JS + construct: false, + noConstructor: true, + finalize: true, // this triggers the deallocation for bun.destroy + configurable: false, + hasPendingActivity: false, + klass: {}, + JSType: "0b11101110", + proto: { + // we put methods here + update: { // name in JS + fn: "update", // name in Zig + length: 1, // quantity of args + }, + + foo: { getter: "foo" }, // public getters, so that histogram.foo is gettable from JS + }, + + values: [], + }), +``` + +- lastly, add the class to `generated_class_list.zig` as `JSC.Node.Histogram` + +Ok, we're almost there. Now we need to look at the Histogram struct for a few subtle things we need to do. + +```javascript +pub const Histogram = struct { + pub usingnamespace JSC.Codegen.JSHistogram; // Important, makes the JS interop work + + // Zig native stuff + foo: u64 = 0, + const This = @This(); + + // We can a custom, arbitrary method, make sure to add it to the class in node.classes.ts + // It must have this function signature: (*Self, globalThis, callframe) callconv(.C) JSValue + pub fn update(self: *This, globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) callconv(.C) JSValue { + // args come from the callframe + const args = callframe.arguments(1).slice(); + if (args.len != 1) { + globalThis.throwInvalidArguments("Expected 1 argument", .{}); + return .zero; + } + self.foo = args[0].to(u64); + return .undefined; // JSValue equivalent of a void return + } + + // Below is a bit of boilerplate we need + + // This boilerplate is for writting getters, at least for trivial types. + const PropertyGetter = fn (this: *This, globalObject: *JSC.JSGlobalObject) callconv(.C) JSC.JSValue; + fn getter(comptime field: meta.FieldEnum(This)) PropertyGetter { + return struct { + pub fn callback(this: *This, globalObject: *JSC.JSGlobalObject) callconv(.C) JSC.JSValue { + const v = @field(this, @tagName(field)); + return globalObject.toJS(v, .temporary); + } + }.callback; + } + + // We can add a getter to make self.foo public. + // Make sure this is also added to node.classes.ts + pub const foo = getter(.foo); + + // since we create Histogram objects with bun.new, we need to have it be destroyable + // our node.classes.ts has finalize=true to generate the call to finalize + // it expects this specific function name to destroy itself. + pub fn finalize(self: *Histogram) callconv(.C) void { + bun.destroy(self); + } +}; +``` + +## Upcoming: + +- actually adding Histogram as an argument into timerify and making timerify call Histogram methods +- implementing all of Histogram