Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,8 @@ tests/lit/**/*.txt
tests/lit/**/Output/
tests/lit/**/header_generator/**/*.h
!tests/lit/**/header_generator/**/dependencies.plc.h

# Local-only baseline / test corpora (not committed)
/.baseline/
/benchmarks/local/

1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions book/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,7 @@
- [Codegen](./arch/codegen.md)
- [CFC](./cfc/cfc.md)
- [Model-to-Model Conversion](./cfc/m2m.md)
- [Development]()
- [Profiling Build Phases](./development/phase_timing.md)
- [Writing a Lowering Pass](./development/writing_a_lowerer.md)
- [Reverse Dependency Graph](./development/reverse_dependency_graph.md)
90 changes: 90 additions & 0 deletions book/src/development/phase_timing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Profiling Build Phases

The compiler driver has a built-in phase timer that records wall-clock
time for each stage of `BuildPipeline` and for every participant
invocation. It is intended for ad-hoc performance work — for example,
when investigating why a particular project compiles slower than
expected, or when measuring the impact of a change to the lowering
pipeline.

## Enabling

Set the environment variable `PLC_TIMING=1` before invoking the
compiler:

```sh
PLC_TIMING=1 plc --check my_project.st
```

When the variable is unset (or set to `0` / empty), the timers compile
to a no-op and emit nothing. There is no behavioural difference and no
test impact, so it is safe to leave the code path always present.

## Reading the output

Each timed scope writes one line to stderr on completion, indented by
nesting depth. Children appear *before* their parent's log line (the
parent prints when it drops, i.e. at end-of-scope), which matches the
standard flame-graph convention.

A condensed example from compiling a small project:

```text
[plc-timing] parse: 25.7ms
[plc-timing] pre_index (participants): 12.6ms
[plc-timing] pre_index/LoopDesugarer: 6.6ms
[plc-timing] pre_index/ControlStatementParticipant: 3.0ms
[plc-timing] ParsedProject::index: 7.4ms
[plc-timing] post_index (participants): 27.6ms
[plc-timing] post_index/PolymorphismLowerer: 11.7ms
[plc-timing] ParsedProject::index: 9.6ms
[plc-timing] post_index/RetainParticipant: 15.8ms
[plc-timing] ParsedProject::index: 5.8ms
[plc-timing] index (driver): 47.6ms
[plc-timing] annotate (driver): 615.2ms
```

A few things to note from this trace:

- The outer `parse`, `index (driver)`, `annotate (driver)`, and
`generate (driver)` scopes correspond to the four top-level phases.
- Each participant invocation is timed individually with a label of the
form `<hook>/<participant-type-name>`, e.g. `post_index/PolymorphismLowerer`.
- `ParsedProject::index` and `IndexedProject::annotate` self-time, so
any participant that re-invokes them appears with a nested
`ParsedProject::index` (or `IndexedProject::annotate`) child line.
Those nested re-passes are the main thing to look for when
investigating slow builds.

## What to look for

The trace is most useful for spotting **redundant whole-project
re-passes**: cases where a participant mutates the AST and then re-runs
indexing or annotation against the entire project, even though only a
few units were touched. A nested `ParsedProject::index` or
`IndexedProject::annotate` under a participant hook is a visible
indicator of one of those re-passes.

## Adding new timed scopes

To time a new scope, construct a `PhaseTimer` and let it drop at the
end of the scope you want to measure:

```rust
use crate::pipelines::timing::PhaseTimer;

fn expensive_thing() {
let _t = PhaseTimer::new("expensive_thing");
// ... work ...
}
```

For participant-style instrumentation, prefer wrapping the *callee*
(the inner method that does the work) rather than each call site. That
way a re-entrant call from a participant gets timed automatically,
without having to thread timer code through every place that might
call into the method.

The timer label is the only argument and accepts any
`Into<String>`. Use a stable, easily-greppable string — these strings
end up in the trace output and may be parsed by tooling.
113 changes: 113 additions & 0 deletions book/src/development/reverse_dependency_graph.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Reverse Dependency Graph

The reverse-dependency graph maps each named symbol to the set of
compilation units that reference it. It is consumed by the lowering
framework to compute partial re-annotation closures: when a lowerer
changes a unit's signature, the units that must be re-annotated are
exactly the union of dependents of every changed signature name, plus
the mutated units themselves.

## Where the code lives

`compiler/plc_driver/src/pipelines.rs`. Public surface:

```rust
pub struct ReverseDependencyGraph { /* private */ }

impl ReverseDependencyGraph {
pub fn dependents(&self, symbol: &str) -> Option<&HashSet<UnitId>>;
pub fn is_empty_for(&self, symbol: &str) -> bool;
pub fn len(&self) -> usize;
pub fn is_empty(&self) -> bool;
}

impl AnnotatedProject {
pub fn reverse_dependencies(&self) -> ReverseDependencyGraph;
}
```

Edges are built from each unit's `Dependency` set, which the type
annotator records in three flavors:

- `Dependency::Call(name)` — direct call sites by callee name.
- `Dependency::Variable(name)` — global variable references by
qualified name.
- `Dependency::Datatype(name)` — type references by name.

`reverse_dependencies()` walks `self.units[i].dependencies()` and
indexes each entry by name → `UnitId::source(i)`.

## Consumers

- `AggregateTypeLowerer::post_annotate`
(`compiler/plc_driver/src/pipelines/participant.rs`) captures the
graph before its STRING-aggregate rewrites so it can ask "who
used these signatures before the rewrite?".
- `AutoLowerer::post_annotate`
(`compiler/plc_driver/src/pipelines/unit_lowerer.rs`) uses the
graph for any `UnitLowerer` registered at post-annotate stage.
- `LoweringBookkeeper::apply_to_annotated`
(`compiler/plc_driver/src/pipelines/bookkeeping.rs`) computes the
re-annotation closure: `mutated_units ∪ dependents(changed_sig)`.

## Closure-coverage contract

The closure is correct only if every cross-unit symbol reference
produces a `Dependency` entry under the callee/referent's name. The
resolver paths that currently uphold this contract:

- [x] Direct function/method calls — `resolve_call` records
`Dependency::Call` under the resolved callee's qualified name.
Verified by `cross_unit_aggregate_signature.st` lit test.
- [x] Interface dispatch (vtable indirection) — records
`Dependency::Datatype` for the interface type and
`Dependency::Call` for each method invoked. Verified by
`cross_unit_polymorphism_dispatch.st` lit test for the
base→derived case.
- [x] Implementing-method signature change reaches the dispatcher —
verified by `cross_unit_interface_dispatch_signature.st`
(added with this PR; see Required regression tests below).
- [ ] Generic monomorphization — a unit that instantiates a generic
function on another unit's type may not record a `Dependency`
under the specialized name. Not yet verified; closure may
under-invalidate. **If you land a feature that depends on
cross-unit generics, add a regression test before relying on
partial re-annotate.**

The first three are required for current consumers. The generic
monomorphization gap is documented as a known risk; no current
consumer exercises it across units.

## Required regression tests

Each new resolver path that contributes to the closure must come with
a lit test that demonstrates the path:

1. **Direct call across units.** A signature change on the callee
invalidates the caller. Covered by
`cross_unit_aggregate_signature.st`.

2. **Interface dispatch.** Unit A defines an interface; unit B
implements it; unit C holds a pointer to the interface and
dispatches through it. Changing the parameter type of B's
implementation must invalidate C. Covered by
`cross_unit_interface_dispatch_signature.st`.

3. **Base→derived vtable.** A method on a derived class is reached
via the base's vtable. Covered by
`cross_unit_polymorphism_dispatch.st`.

4. **Generic specialization** (open). A unit that instantiates a
generic from another unit on a type from a third unit. Add when
the first consumer crosses unit boundaries with generics.

## When the closure is wrong

A miss in the closure means a stale annotation survives into codegen.
Symptoms: a type mismatch between caller and callee that the
annotator should have caught, but which surfaces as an LLVM-level
crash or a silently miscompiled call. If you suspect a miss, you can
verify by running the same project with `PLC_TIMING=1` and comparing
the closure size against a fresh full-reannotate baseline — if the
miss is exercised, the partial closure will be strictly smaller than
the full one in a way that matters.
Loading
Loading