Skip to content
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
33 changes: 33 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,39 @@ first. Measure with `python -m bench ab BASE --bench NAME`, and put the time del
(only if it exceeds the reported noise) and the allocation delta in the commit
message. For a new feature with no base number, use `bench linearity --bench NAME`.

## Performance investigations

Before profiling or choosing an optimization, read `docs/12` § 4.1–4.2 and
§ 5.1–5.2. Start from the user operation and its cost, not a profiler's ranking.

- Describe the scenario: input dimensions, ordered requests/edits, measured
boundaries, retained results, and which work occurs automatically or on demand.
Separate verified client behavior from a proposed representative scenario.
A corpus name and `unwrap`/`service` label are not a workload description.
- Map each relevant cache's owner, key, first population, sharing, invalidation
and release. Distinguish cold process, cold snapshot, first file query, warm
query, and the next edit; include failed composition and unchanged dependencies
where relevant. Check these boundaries with counters or reach tests.
- Establish unprofiled end-to-end and phase costs first. Select a profiler to
answer a named question. Use sampling for elapsed-time attribution where
supported, targeted counters for work multiplicity, and allocation tools for
ownership. Use scoped `cProfile` only when Python call paths/counts are the
question; its instrumented timings are not benchmark results.
- Before making a frequently called function cheaper, explain its callers and
expected calls per file, distinct node/edge, materialization, request or cache
miss. Check growth against those dimensions. Fix unintended repeated work or
an incorrect cache boundary first; optimize the call itself only when the
remaining multiplicity is justified and its cost matters end to end.
- Measure parse, first-use and warm-query costs separately, then the real request
mix over edits. Account for eager work moved into parsing, automatic outline/
inlay/folding requests, includes and multiple roots. Neither an idle snapshot
nor thousands of hovers alone establishes the editor's latency or memory cost.
- Keep correctness, scaling and absolute-cost acceptance separate. Compare the
same observable work on identical corpora; distinguish unavailable historical
APIs from absent historical behavior. Report time/noise, peak and retained
allocation, cache state and trade-offs. Stop optimizing when the stated goal
is met; a green linearity run alone does not accept a slower implementation.

## Layers and import boundaries

- `fastraml/parser/`, `fastraml/types/`: the passes P0–P10. A rule the RAML language
Expand Down
18 changes: 18 additions & 0 deletions bench/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ def _at(base: int, scale: float) -> int:
Bench('hover', lambda root, scale: corpus.write_hover(root, family_count=_at(400, scale))),
Bench('effective-types', lambda root, scale: corpus.write_hover(root, family_count=_at(300, scale))),
Bench('inlays', lambda root, scale: corpus.write_hover(root, family_count=_at(400, scale))),
Bench('service-session', lambda root, scale: corpus.write_hover(root, family_count=_at(400, scale))),
Bench('service-source-first', lambda root, scale: corpus.write_hover(root, family_count=_at(400, scale))),
)

_BY_NAME = {bench.name: bench for bench in BENCHES}
Expand Down Expand Up @@ -159,6 +161,8 @@ def run_one(bench: str, config: str, entry: Path, repeat: int) -> Measurement:
'hover',
'effective-types',
'inlays',
'service-session',
'service-source-first',
}:
return _measure_view(bench, entry, repeat)
if config == 'service':
Expand Down Expand Up @@ -202,6 +206,8 @@ def _measure_view(bench: str, entry: Path, repeat: int) -> Measurement:
'hover': _measure_hover,
'effective-types': _measure_effective_types,
'inlays': _measure_inlays,
'service-session': _measure_service_session,
'service-source-first': lambda entry, repeat: _measure_service_session(entry, repeat, source_first=True),
}.get(bench)
if service_workload is not None:
return service_workload(entry, repeat)
Expand Down Expand Up @@ -363,6 +369,16 @@ def hints() -> object:
return measure('inlays', 'unwrap', hints, repeat=repeat)


def _measure_service_session(entry: Path, repeat: int, *, source_first: bool = False) -> Measurement:
from bench.service_session import exercise, prepare # noqa: PLC0415 - feature workload only
from fastraml.gctuning import tuned_gc # noqa: PLC0415 - feature workload only

prepared = prepare(entry)
name = 'service-source-first' if source_first else 'service-session'
with tuned_gc():
return measure(name, 'unwrap', lambda: exercise(prepared, source_first=source_first), repeat=repeat)


def _measure_edit(bench: str, entry: Path, repeat: int) -> Measurement:
"""One edit to the root's buffer, and what the editor then asks for first."""
from itertools import count # noqa: PLC0415 - as above
Expand Down Expand Up @@ -531,6 +547,8 @@ def compare(results: Sequence[Measurement], tolerance: float) -> int:
'hover': 'unwrap',
'effective-types': 'unwrap',
'inlays': 'unwrap',
'service-session': 'unwrap',
'service-source-first': 'unwrap',
}


Expand Down
112 changes: 112 additions & 0 deletions bench/service_session.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"""Representative editor requests across three versions, not a client trace."""

from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING

from fastraml.positions import Position
from fastraml.service import inlays, lenses, outline, queries
from fastraml.service.workspace import Workspace
from fastraml.uris import path_to_file_uri

if TYPE_CHECKING:
from pathlib import Path


@dataclass(frozen=True, slots=True, eq=False)
class SessionInput:
root: str
folder: str
focus: str
focus_text: str
versions: tuple[str, ...]
probes: tuple[tuple[int, int], ...]


def prepare(entry: Path, *, focus: Path | None = None) -> SessionInput:
"""Prepare texts and sparse probes outside measurement."""
target = entry if focus is None else focus
text = entry.read_text(encoding='utf-8')
focus_text = target.read_text(encoding='utf-8')
candidates = [
(line, len(raw) - len(raw.lstrip()) + 1)
for line, raw in enumerate(focus_text.splitlines(), 1)
if raw.lstrip().startswith(('minLength:', 'maxLength:', 'type:', 'description:'))
]
if not candidates:
raise RuntimeError('session corpus lost its sparse hover sites')
probes = tuple(candidates[index] for index in (0, len(candidates) // 2, len(candidates) - 1))
return SessionInput(
path_to_file_uri(entry),
path_to_file_uri(entry.parent),
path_to_file_uri(target),
focus_text,
(text, text + '\n# edit 2\n', text + '\n# edit 3\n'),
probes,
)


def folding(workspace: Workspace, uri: str) -> list[tuple[int, int]]:
"""Use the branch's public structural-query interface."""
if not hasattr(workspace, 'source'):
return queries.folding_ranges(workspace.text(uri) or '', uri)
source = workspace.source(uri)
if hasattr(source, 'folding_ranges'):
return source.folding_ranges()
return getattr(queries, 'folding_ranges_of')(source) # noqa: B009 - historical branch API


def selection(workspace: Workspace, uri: str, line: int, column: int) -> list[Position]:
"""Use the branch's public structural-query interface."""
if not hasattr(workspace, 'source'):
return queries.selection_ranges(workspace.text(uri) or '', uri, line, column)
source = workspace.source(uri)
if hasattr(source, 'selection_ranges'):
return source.selection_ranges(line, column)
return getattr(queries, 'selection_ranges_of')(source, line, column) # noqa: B009 - historical branch API


def exercise(prepared: SessionInput, *, source_first: bool = False) -> tuple[Workspace, dict[str, int]]:
"""Keep the current workspace/caches, discarding each request's answer."""
workspace = Workspace([prepared.folder])
if prepared.focus != prepared.root:
workspace.open(prepared.focus, prepared.focus_text, 1)
counts: dict[str, int] = dict.fromkeys(
('snapshots', 'outlines', 'lenses', 'hints', 'folds', 'hovers', 'selections'), 0
)
viewport = Position(1, 1, 120, 1)
for version, text in enumerate(prepared.versions, 1):
workspace.change(prepared.root, text, version)
workspace.collect()
if source_first:
counts['folds'] += len(folding(workspace, prepared.focus))
snapshot = workspace.snapshot(prepared.root)
if snapshot.error is not None or snapshot.raml is None:
raise RuntimeError('session corpus lost its valid semantic snapshot')
counts['snapshots'] += 1
queries.diagnostics(snapshot, lint=False)
snapshot.occurrences # noqa: B018 - navigation index is part of the request mix
counts['outlines'] += len(outline.document_symbols(snapshot, prepared.focus))
queries.links(snapshot, prepared.focus)
counts['lenses'] += len(lenses.code_lenses(snapshot, prepared.focus))
counts['hints'] += len(inlays.inlay_hints(snapshot, prepared.focus, viewport))
counts['folds'] += len(folding(workspace, prepared.focus))
for line, column in prepared.probes:
if queries.hover(snapshot, prepared.focus, line, column) is None:
raise RuntimeError('session corpus lost a sparse hover answer')
counts['hovers'] += 1
# Warm requests in the same version; selection is user-triggered.
counts['outlines'] += len(outline.document_symbols(snapshot, prepared.focus))
counts['hints'] += len(inlays.inlay_hints(snapshot, prepared.focus, viewport))
counts['folds'] += len(folding(workspace, prepared.focus))
for index in range(8):
line, column = prepared.probes[index % len(prepared.probes)]
if not selection(workspace, prepared.focus, line, column):
raise RuntimeError('session corpus lost a selection path')
counts['selections'] += 1
# Release the previous model before collect/build on the next edit.
del snapshot
if not all(counts.values()):
raise RuntimeError('session corpus no longer reaches every request kind')
return workspace, counts
Loading
Loading