Skip to content

IR

ppy_compiler.ir is the typed canonical IR that sits between analysis and the backends. Its modules:

  • model: the data structure
  • types: the types
  • dialect: the extension point
  • verify: the rules
  • printer, parser, codec: the text form

The IR describes the format and the dialects.

Model

ppy_compiler.ir.model

The IR's data model: modules, functions, blocks, operations, values.

Typed SSA with an explicit control-flow graph. Every value is defined once -- as a block argument or as the result of one operation -- and carries one type; every block ends in one terminator; branches pass arguments to the blocks they reach. The structure is plain objects with use lists, so a rewrite can replace a value everywhere it is read and a verifier can walk what it wants.

Nothing here knows what an operation means. An operation is a name in a dialect, operands, results, attributes, successors, and nested regions; the dialect's OpSpec says what is allowed, and the verifier asks it.

SymbolRef(name) dataclass

A reference to a symbol by name: the @callee of a call.

Symbol(name, visibility='public') dataclass

A name in the module's symbol table and who may see it.

Value(type_, name=None)

Something an operation reads: a block argument or an operation result.

replace_all_uses_with(other)

Every read of this value reads other from now on.

Successor(block, arguments=())

A branch target with the arguments it receives.

Region(parent=None)

A list of blocks; the first is the entry.

function property

The function this region belongs to, however deeply it nests.

Block(name, region=None)

terminator_for(registry=None)

The final operation when registry defines it as a terminator.

successors_for(registry=None)

Blocks reached by this block's terminator in registry.

Operation(name, operands=(), result_types=(), attributes=None, successors=(), location=None, result_names=())

One operation: results = dialect.name operands successors attrs regions.

erase()

Remove this operation from its block and drop every use it holds.

Global(symbol, type, value=None, constant=True, location=None) dataclass

A module-level constant or variable.

IRFunction(symbol, params, results, attributes=None, location=None)

add_entry_block()

The entry block, with one argument per parameter, named after it.

operations()

Every operation in the function, nested regions included, in order.

IRModule(name, dialects=None)

require(dialect, version)

Record that the module uses dialect at version.

Builder(block=None, location=None)

Appends operations at an insertion point.

The dialect modules put their typed constructors on top of create: core.add(builder, x, y, overflow="python").

Types

ppy_compiler.ir.types

The types a value in the IR can have, and their text.

Every value has exactly one type. The core types are the ones every backend must know -- machine scalars, pointers, buffers, vectors, tuples, structs, and futures -- and a dialect adds its own through DialectType, which the core parses generically as name<args> and the dialect verifies.

A type is an immutable value that compares by content and prints to one spelling, so the same program always prints to the same bytes.

TypeError_

Bases: ValueError

A type that cannot be spelled or parsed.

IRType() dataclass

Base of every IR type. Subclasses are frozen dataclasses.

BFloat16Type() dataclass

Bases: IRType

Brain floating point: 8 exponent bits and 7 explicit fraction bits.

This is distinct from IEEE binary16 and deliberately not a FloatType, so a backend dispatching IEEE types by width cannot substitute f16.

IndexType() dataclass

Bases: IRType

A size or offset: as wide as a pointer on the target.

PtrType(pointee, address_space='generic', mutable=True) dataclass

Bases: IRType

ptr<T, space, mut|const>: a pointer to T in an address space.

stack memory comes from core.alloca and may not escape the function; generic is anything the host addresses. A dialect adds spaces of its own (global, shared, ...).

BufferType(element) dataclass

Bases: IRType

buffer<T>: contiguous elements with a length the program can read.

VectorType(element, count) dataclass

Bases: IRType

vector<T, N>: N scalars operated on at once.

StructType(name, fields) dataclass

Bases: IRType

struct<Name, f: T, ...>: named fields with a nominal identity.

DialectType(dialect, name, args=()) dataclass

Bases: IRType

dialect.name<args>: a type a dialect owns.

The core keeps the arguments as parsed -- types, integers, or bare words -- and the dialect's verifier gives them meaning.

is_arithmetic(t)

Something core.add and friends operate on: a number, or a vector of them.

scalar_of(t)

The element of a vector, or the type itself.

parse_type(text)

The type text spells; raises TypeError_ on anything else.

Dialects

ppy_compiler.ir.dialect

Dialects: the units the IR is extended in.

A dialect owns a namespace of operation names and types, and says what each operation requires -- how many operands and results, which attributes, what the types must be -- through an OpSpec the verifier consults. The core dialect is one of them; every other is registered the same way, by the compiler or by a plugin, and a module records which dialects it uses at which version so that a reader without one refuses it instead of guessing.

OpSpec(name, terminator=False, pure=False, commutative=False, variant_attribute=None, variants=(), inline_attribute=None, verify=None, operands=None, results=None, required_attributes=(), successors=None, regions=0) dataclass

What one operation name requires, and what it may be treated as.

Dialect

A namespace of types and operations; subclasses register theirs.

register_types(registry)

Types this dialect owns are verified by verify_type; nothing to do by default.

register_operations(registry)

Add this dialect's OpSpecs: registry.add_op(spec).

register_patterns(registry)

Canonicalization patterns (ir.pattern); nothing by default.

register_lowerings(registry)

Lowerings to other dialects or to a backend; nothing by default.

verify_type(t)

Why t is not a type this dialect defines, or None when it is.

address_spaces()

Address spaces this dialect gives pointers.

verify_function(function, checker)

Rules over a whole function -- its attributes, what its body may hold.

The verifier asks every registered dialect after a function's own checks; the default has nothing to say.

DialectRegistry()

The dialects a compiler knows, and every operation they define.

add_pattern(pattern)

A rewrite pattern that belongs to no dialect of its own.

verify_type(t)

Why a type is not one any registered dialect defines, or None.

registry()

The process-wide registry, with the builtin dialects registered.

Passes

ppy_compiler.ir.passes

The pass manager: passes over modules, in order, with what they need.

A pass names the analyses it requires, preserves, and invalidates, and the context serves analyses from a cache that a pass's own declaration empties. Between passes the manager can verify the module, so that a pass which breaks it is named in the error rather than found by the next one. Plugins add passes at named stages; the manager runs them where the stage falls in the pipeline.

PassContext(registry=None, *, verify_after_each=False, options=None)

What passes share: the registry, options, remarks, and cached analyses.

analysis(name, function)

The named analysis of function, computed once until invalidated.

invalidate(function=None, keep=())

Drop cached analyses except keep, for one function or all.

cached()

(analysis, function name) pairs currently cached, for tests.

Pass

One transformation of a module.

run(module, ctx)

Transform module; return whether anything changed.

FunctionPass

Bases: Pass

A pass that works one function at a time.

PassReport(entries=list()) dataclass

What ran, whether it changed anything, and how long it took.

PassVerificationError(pass_name, errors)

Bases: Exception

A pass left the module invalid; the pass is named.

PassManager(ctx=None)

add_stage(stage)

Mark where passes registered for stage run.

register_stage_pass(stage, factory)

A pass a plugin adds; it runs wherever the pipeline marks stage.

passes()

The passes in the order they will run, stage passes expanded.

Patterns

ppy_compiler.ir.pattern

Rewriting the IR by patterns, to a fixed point.

A pattern looks at one operation and either rewrites it through the rewriter -- which is the only way anything is changed, so every change is recorded and every affected operation is looked at again -- or says why it does not apply. The greedy driver applies a set of patterns until none applies, with a cap that turns a pattern that never settles into an error naming it rather than a compiler that never finishes.

Pattern

One rewrite: the operation it roots at, and the rewrite itself.

Rewriter()

How a pattern changes the IR; every change goes through here.

builder(op)

A builder inserting before op.

replace_op(op, values, reason='')

Every result of op now reads the matching value; op goes away.

A None keeps a result in place -- for a result nothing reads.

RewriteDidNotConverge

Bases: RuntimeError

A pattern set kept rewriting past the iteration cap.

GreedyRewriteDriver(patterns, max_iterations=10000)

Apply patterns until none applies, revisiting what each rewrite touched.

run(function, rewriter=None)

The number of changes made.

Verification

ppy_compiler.ir.verify

The verifier: what every module must satisfy before anything reads it.

Structure first -- every block ends in one terminator, branches reach blocks of the same region with the arguments those blocks take, every value is defined before it is used along every path, every symbol a call names exists -- then the dialects' own rules, operation by operation. A failure is a list of errors with the operation each sits on, never an exception out of the middle of a walk; verify_or_raise turns the list into one exception for callers that want that.

VerificationError(errors)

Bases: Exception

A module the verifier refused, with every error it found.

Checker(registry, module)

Collects errors with their position; dialect rules call error.

verify(module, registry=None)

Every error in module; an empty list means it is well-formed.

The linker

ppy_compiler.ir.linker

The IR linker: one program from a project's module IRs (spec 10, 11).

Every module lowers on its own to an IRModule whose calls into another module go through a declaration -- a function with no body, carrying the callee's symbol. Linking puts the modules together: a definition answers the declarations of its symbol, which go away; two definitions of one symbol are one when both are the same generic instance (a specialization each module made for itself) and an error otherwise; a private symbol -- a string constant, a helper -- that two modules both spell is renamed after its module, and every reference follows. The dialects a module needs the program needs, at the newest version any asked for; the libraries a module links the program links. What is left unresolved is reported by name, so the driver can keep its callers on CPython the way the frontend keeps a caller of a function that did not lower.

The linked module is what whole-program optimization runs on and what ppy emit ir --linked prints; a backend sees one module either way.

LinkError

Bases: ValueError

Two modules define one public symbol differently, or a module names a dialect twice.

Link modules, in order, into one program named name.

unresolved(program)

The functions program declares and no one defines, that its code calls.

Text

ppy_compiler.ir.codec

.ppyir: the IR on disk, versioned.

The text the printer writes is the format: a header naming the schema version and every dialect the module uses with its version, then the module. Reading checks the header before anything else -- a newer schema, an unknown dialect, or a dialect at a version this compiler does not have is refused with the reason, not parsed on the hope that it is close enough. Encoding is deterministic: the same module encodes to the same bytes, whatever built it.

The format is experimental in 0.2.0: a reader may refuse what an older writer wrote, and says so.

CodecError

Bases: ValueError

Text this compiler cannot read as IR, with the reason.

encode(module, registry=None)

The module as .ppyir text.

decode(text, registry=None)

The module text holds; refuses a schema or dialect it does not have.

ppy_compiler.ir.printer

The IR as text, one spelling per module.

Values are named in definition order: a value keeps the name it was given when no earlier value in the function took it, and is numbered otherwise. Attributes print sorted by key. So two modules with the same content print to the same bytes, and a module printed after being parsed prints the same text again.

print_module(module, registry=None)

The module's text, including the header the codec reads.

print_attribute(value)

One attribute value as the parser reads it back.

ppy_compiler.ir.parser

Reading the text the printer writes.

One cursor over the source; types are parsed by the type grammar's own parser at that cursor, so there is one spelling of a type. Every value is resolved when the function ends, so a branch may name a block or a value that is defined later in the text; a name defined twice or never is an error with its line.

parse_module(text, registry=None)

The module text spells; the header is validated by the codec.

Shared passes

ppy_compiler.ir.transforms

Passes over the IR that every pipeline shares.

AutodiffError

Bases: ValueError

A function the transform cannot differentiate, with the reason.

FuseTensor()

Bases: FunctionPass

TensorCanonicalize

Bases: FunctionPass

The tensor dialect's own patterns, on their own (canonicalize includes them).

AsyncLoweringError

Bases: ValueError

A coroutine the state machine cannot hold: a buffer, a vector.

LowerParallel(backend='threads', threads=1, minimum=4096)

Bases: Pass

Rewrite every parallel.* operation for the selected backend.

LowerRegex

Bases: Pass

Rewrite every regex.* operation as a call to its pattern's matcher.

LoweringError

Bases: ValueError

Tensor IR the lowering cannot make memory of, with the reason.

LowerTensor(lapack=True)

Bases: Pass

Rewrite every tensor, linalg, fft, sparse, columnar, and arrow operation as core loops.

lapack says whether the factorizations may call LAPACK; without it they are refused with the reason.

AnnotateProfile(profile)

Bases: Pass

A profile's counts as attributes on the functions and terminators it measured.

FunctionProfile(symbol='', cfg='', calls=0, blocks=dict(), branches=dict(), arguments=dict(), generic='') dataclass

What one function did: its calls, its blocks, its branches, its arguments.

merge(other)

Add other's counts, when it measured the same graph; take it whole otherwise.

Instrument

Bases: Pass

A counter in every block and on every conditional branch; the legend on the module.

Profile(functions=dict(), runs=0, program='', compiler='', path='') dataclass

A .ppyprof: every profiled function by qualified name, over one or more runs.

hot_threshold()

A function this often called is hot: a twentieth of the most-called one, at least 2.

kind(qualname)

hot, warm, or cold for a measured function; None for one the profile lacks.

write(path)

Write, merging into a profile already at path; the profile as written.

ProfileError

Bases: Exception

A .ppyprof that is missing, unreadable, or not a profile.

Sanitize(kinds)

Bases: FunctionPass

Insert the checks kinds ask for.

SimplifyCFG

Bases: FunctionPass

GlobalDCE

Bases: Pass

Private functions and globals nothing public reaches go away.

Inline(budget=INLINE_BUDGET)

Bases: Pass

A small callee, or one marked ppy.inline, is copied into its callers.

differentiate(module, function, *, wrt=(0,), value=False, name=None)

The gradient of function with respect to the parameters at wrt.

A scalar parameter's gradient is a result, in wrt order after the value when value is asked for; a buffer parameter's gradient is written into a buffer parameter added after the function's own, one per buffer in wrt, named grad_<parameter>.

canonicalization_patterns(registry)

What every registered dialect contributes.

cfg_digest(function, registry=None)

The shape a profile keys on: every block's label, terminator, and successors.

sanitizer_kinds(spelled)

The kinds --sanitize a,b or a configured list names; a refusal names why.

internalize(module, keep)

Every definition not in keep and not handed out by address becomes private.

default_pipeline(level=1, ctx=None, parallel=None, sanitize=(), until=None, instrument=False, profile=None, lower_tensors=True)

The passes a module goes through before a backend, by optimization level.

parallel is the LowerParallel pass for the build's configuration; it runs after the optimizations and the result is cleaned up again. sanitize names the sanitizers a build asked for; their checks go in before the optimizations, so what the optimizer removes they still hold. until stops the pipeline at a point -- none before any pass, after-canonicalization, after-fusion -- for ppy inspect --stage. instrument places the profile counters, profile applies a recorded profile; both work right after canonicalization, so a profile keys on the graph a build sees at the same point.