Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
28 changes: 28 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,34 @@ Follow these instructions.
- **Common patterns**: For large package moves, do physical file moves first, then repo-wide import rewrites, then trim formatting-only churn to keep PR review focused.
- **Gotchas**: Broad `gofmt` runs can introduce noisy doc-comment/newline changes in unrelated files; revert those hunks unless they are intentional.

### Session Notes (2026-02-14, runtime benchmarks baseline)

- **Architecture insights**: Runtime e2e benchmarks should pre-build CLI + benchmark program once, then measure only execution of the compiled `output` binary.
- **Common patterns**: Keep runtime benchmark fixtures self-contained by copying `benchmarks/neva.yml` and the benchmark `.neva` file into a temp module instead of relying on `chdir`.
- **Common patterns**: Track runtime regressions with both e2e execution benchmarks and focused `internal/runtime` microbenchmarks (message operations and single-port round-trip).
- **Gotchas**: Full `go test ./...` can run very long due e2e coverage; benchmark-only changes should still validate touched packages + benchmark smoke runs.

### Session Notes (2026-02-14, PR-1023 review follow-up)

- **Common patterns**: Reuse `pkg/e2e` helpers (`FindRepoRoot`, CLI build helper) inside benchmark harnesses to avoid duplicated repo-root/build logic.
- **Common patterns**: For long-term comparable perf baselines, prefer runtime e2e benchmarks over runtime-internal microbenchmarks that may track unstable APIs.
- **Gotchas**: `thelper` expects parameters typed as `testing.TB` to be named `tb`.

### Session Notes (2026-02-14, runtime e2e suite expansion)

- **Common patterns**: Model runtime perf baselines as sub-benchmarks over multiple precompiled Neva programs (int/bool/string/float/list/dict/struct/union/combo) built once per case.
- **Gotchas**: In Neva, constant senders in handlers must be attached to a triggering chain; standalone `const -> port` lines inside helper defs can fail analyzer checks.
- **Architecture insights**: A single Go benchmark harness can compile each Neva package in an isolated temp module and execute only the produced `output` binary to keep focus on runtime execution latency.

### Session Notes (2026-02-14, benchmark docs comments)

- **Common patterns**: Keep short intent comments at top of each benchmark `main.neva` and helper defs so runtime benchmark purpose is obvious during review.

### Session Notes (2026-02-15, runtime benchmark stabilization)

- **Gotchas**: `Select` consumes all `then[*]` inputs for each `if` trigger; wiring multiple trigger sources per iteration can deadlock benchmarks.
- **Common patterns**: For heavy router/selector e2e runtime benchmarks, keep workload lower (e.g. `Range:to = 10000`) so local smoke runs stay under execution timeout.
- **Gotchas**: Naming a benchmark package root `runtime` can collide with stdlib import resolution; use a distinct root like `runtime_bench`.

### Session Notes (2026-02-15)

Expand Down
189 changes: 166 additions & 23 deletions benchmarks/message_passing/bench_test.go
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[файл-обзор] Этот файл удалён: старый harness заменён единым benchmarks/bench_test.go.

Original file line number Diff line number Diff line change
@@ -1,38 +1,181 @@
// Remember - Go runs benchmark function twice:
// first time for calibration, second time for actual benchmark.
// This might affect working with relative paths!

package test

import (
"context"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"testing"
"time"

"github.com/stretchr/testify/require"
"github.com/nevalang/neva/pkg/e2e"
)

func BenchmarkMessagePassing(b *testing.B) {
// Store original working directory
originalWd, err := os.Getwd()
require.NoError(b, err)
// BenchmarkRuntimeE2E benchmarks precompiled runtime programs by data type path.
func BenchmarkRuntimeE2E(b *testing.B) {
// Build the CLI once and reuse it for all benchmark programs.
repoRoot := e2e.FindRepoRoot(b)
nevaBin := e2e.BuildNevaBinary(b, repoRoot)

benchPkgs, err := discoverRuntimeBenchPkgs(repoRoot)
if err != nil {
b.Fatalf("discover runtime benchmark packages: %v", err)
}

for _, benchPkg := range benchPkgs {
benchName := strings.ReplaceAll(benchPkg, string(filepath.Separator), "_")
b.Run(benchName, func(b *testing.B) {
// Build the benchmark program once outside timed iterations.
progPath := buildProgramOnce(b, repoRoot, nevaBin, benchPkg)

b.ReportAllocs()
b.ResetTimer()

// Change to parent directory
err = os.Chdir("..")
require.NoError(b, err)
for b.Loop() {
runProgramBinary(b, progPath)
}
})
}
}

// discoverRuntimeBenchPkgs finds all benchmark packages under benchmarks/runtime_bench.
func discoverRuntimeBenchPkgs(repoRoot string) ([]string, error) {
benchmarksRoot := filepath.Join(repoRoot, "benchmarks")
runtimeRoot := filepath.Join(benchmarksRoot, "runtime_bench")
pkgs := make([]string, 0, 64)

// Ensure we return to original directory after benchmark
defer func() {
err := os.Chdir(originalWd)
require.NoError(b, err)
}()
walkErr := filepath.WalkDir(runtimeRoot, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || d.Name() != "main.neva" {
return nil
}

pkgDir := filepath.Dir(path)
relDir, relErr := filepath.Rel(benchmarksRoot, pkgDir)
if relErr != nil {
return fmt.Errorf("resolve relative package dir for %q: %w", path, relErr)
}
pkgs = append(pkgs, relDir)
return nil
})
if walkErr != nil {
return nil, walkErr
}

sort.Strings(pkgs)
return pkgs, nil
}

// buildProgramOnce prepares an isolated module and compiles one benchmark package.
func buildProgramOnce(b *testing.B, repoRoot, nevaBin, pkgName string) string {
b.Helper()

// Create an isolated temp module workspace for one benchmark package.
tmpDir := b.TempDir()
homeDir := filepath.Join(tmpDir, "home")
moduleDir := filepath.Join(tmpDir, "bench-module")
progDir := filepath.Join(moduleDir, pkgName)
if err := os.MkdirAll(progDir, 0o755); err != nil {
b.Fatalf("create benchmark module dirs: %v", err)
}
if err := prepareBenchmarkHome(repoRoot, homeDir); err != nil {
b.Fatalf("prepare benchmark home: %v", err)
}

// Copy benchmark fixture files into the isolated module.
copyFile(b, filepath.Join(repoRoot, "benchmarks", "neva.yml"), filepath.Join(moduleDir, "neva.yml"))
copyFile(
b,
filepath.Join(repoRoot, "benchmarks", pkgName, "main.neva"),
filepath.Join(progDir, "main.neva"),
)

// Compile the benchmark program once and return its output binary.
buildProg := exec.Command(nevaBin, "build", pkgName)
buildProg.Dir = moduleDir
buildProg.Env = append(os.Environ(), "HOME="+homeDir)
if output, err := buildProg.CombinedOutput(); err != nil {
b.Fatalf("build benchmark program: %v\n%s", err, output)
}

// Reset timer after setup
b.ResetTimer()
return filepath.Join(moduleDir, "output")
}

// prepareBenchmarkHome creates an isolated Neva home with local stdlib available.
func prepareBenchmarkHome(repoRoot, homeDir string) error {
nevaHome := filepath.Join(homeDir, "neva")
if err := os.MkdirAll(nevaHome, 0o755); err != nil {
return err
}

stdSrc := filepath.Join(repoRoot, "std")
stdDst := filepath.Join(nevaHome, "std")
if err := os.Symlink(stdSrc, stdDst); err == nil {
return nil
}

return copyDir(stdSrc, stdDst)
}

// copyDir copies a directory recursively, preserving file modes.
func copyDir(src, dst string) error {
return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, relErr := filepath.Rel(src, path)
if relErr != nil {
return relErr
}
target := filepath.Join(dst, rel)
if d.IsDir() {
return os.MkdirAll(target, 0o755)
}

data, readErr := os.ReadFile(path)
if readErr != nil {
return readErr
}
// #nosec G306 -- benchmark fixture files are read-only test assets.
return os.WriteFile(target, data, 0o644)
})
}

// runProgramBinary executes one precompiled benchmark binary.
func runProgramBinary(b *testing.B, progPath string) {
b.Helper()

// #nosec G204 -- benchmark executes a fixed local binary built during setup.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

cmd := exec.CommandContext(ctx, progPath)
output, err := cmd.CombinedOutput()
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
b.Fatalf("run benchmark program timed out: %s", progPath)
}
b.Fatalf("run benchmark program: %v\n%s", err, output)
}
}

// copyFile copies one fixture file into the temp benchmark module.
func copyFile(b *testing.B, src, dst string) {
b.Helper()

data, err := os.ReadFile(src)
if err != nil {
b.Fatalf("read %s: %v", src, err)
}

for b.Loop() {
cmd := exec.Command("neva", "run", "message_passing")
out, err := cmd.CombinedOutput()
require.NoError(b, err, string(out))
// #nosec G306 -- benchmark fixture file should be readable for local runs/inspection.
if err := os.WriteFile(dst, data, 0o644); err != nil {
b.Fatalf("write %s: %v", dst, err)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Benchmarks a more complex mixed control-flow workload.
// Combines selectors, routers, and union wrapping/unwrapping.
import {
runtime
streams
}

type Number union {
Int int
}

def Main(start any) (stop any) {
range streams.Range
map_complex streams.Map<int, int>{ComplexFlow}
wait streams.Wait
---
:start -> [
1 -> range:from,
10000 -> range:to
]
range -> map_complex -> wait -> :stop
}

def ComplexFlow(data int) (res int) {
mod Mod
eq Eq<int>
tern Ternary<int>
match Match<int>
cond Cond<int>
switch Switch<int>
select Select<int>
race Race<int>
box Union<Number>
unwrap Switch<Number>
del Del
panic runtime.Panic
---
:data -> [
mod:left,
2 -> mod:right,
10 -> tern:then,
20 -> tern:else,
10 -> match:if[0],
20 -> match:if[1],
1 -> match:then[0],
2 -> match:then[1],
1 -> match:else,
1 -> switch:case[0],
2 -> switch:case[1],
1000 -> select:then[0],
2000 -> select:then[1],
3000 -> select:then[2]
]
mod -> [eq:left, 0 -> eq:right]
eq -> [tern:if, cond:if]
tern -> match:data
match -> [cond:data, switch:data]
[cond:then, cond:else] -> race:data
switch:case[0] -> [race:case[0], select:if[0]]
switch:case[1] -> [race:case[1], select:if[1]]
switch:else -> select:if[2]
select -> del
[race:case[0], race:case[1]] -> [box:data, Number::Int -> box:tag]
box -> [unwrap:data, Number::Int -> unwrap:case[0] -> :res]
unwrap:else -> panic
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Benchmarks a composite data path: struct + union together.
// This models realistic mixed-type runtime traffic.
import {
runtime
streams
}

type Number union {
Int int
}

type Envelope struct {
id int
boxed Number
}

def Main(start any) (stop any) {
range streams.Range
map_pack streams.Map<int, Envelope>{Pack}
map_unpack streams.Map<Envelope, int>{Unpack}
wait streams.Wait
---
:start -> [
1 -> range:from,
100000 -> range:to
]
range -> map_pack -> map_unpack -> wait -> :stop
}

// Pack creates an envelope that contains both plain and tagged fields.
def Pack(data int) (res Envelope) {
box Union<Number>
builder Struct<Envelope>
---
:data -> [
box:data,
Number::Int -> box:tag,
builder:id
]
box -> builder:boxed
builder -> :res
}

// Unpack extracts tagged payload from nested struct field.
def Unpack(data Envelope) (res int) {
switch Switch<Number>
panic runtime.Panic
---
:data -> .boxed -> [
switch:data,
Number::Int -> switch:case[0] -> :res
]
switch:else -> panic
}
24 changes: 24 additions & 0 deletions benchmarks/runtime_bench/simple/collections/basic/main.neva
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Benchmarks collection helpers from builtin package.
// Covers Len over list constants.
import { streams }

const nums list<int> = [1, 2, 3, 4, 5]

def Main(start any) (stop any) {
range streams.Range
map_col streams.Map<int, int>{CollectionOps}
wait streams.Wait
---
:start -> [
1 -> range:from,
100000 -> range:to
]
range -> map_col -> wait -> :stop
}

def CollectionOps(data int) (res int) {
len_list Len
---
:data -> $nums -> len_list:data
len_list -> :res
}
Loading