Technical Documentation: facebook/hhvm
ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference
Overview (README.md)
HHVM
HHVM page |
HHVM documentation |
Hacklang page |
General group |
Dev group |
Twitter
HHVM is an open-source virtual machine designed for executing programs written in Hack. HHVM uses a just-in-time (JIT) compilation approach to achieve superior performance while maintaining amazing development flexibility.
HHVM should be used together with a webserver like the built in, easy to deploy Proxygen, or a FastCGI-based webserver on top of nginx or Apache.
Installing
If you're new, try our getting started guide.
You can install a prebuilt package or compile from source.
Running
You can run standalone programs just by passing them to hhvm: hhvm example.hack.
If you want to host a website:
- Install your favorite webserver. Proxygen is built into HHVM, fast and easy to deploy.
- Install our package
- Start your webserver
- Run
sudo /etc/init.d/hhvm start - Visit your site at
http://.../main.hack
Our getting started guide provides a slightly more detailed introduction as well as links to more information.
Contributing
We'd love to have your help in making HHVM better. If you're interested, please read our guide to contributing.
License
HHVM is licensed under the PHP and Zend licenses except as otherwise noted.
The Hack typechecker is licensed under the MIT License except as otherwise noted.
The Hack Standard Library is licensed under the MIT License except as otherwise noted.
Reporting Crashes
See Reporting Crashes for helpful tips on how to report crashes in an actionable manner.
Security
For information on reporting security vulnerabilities in HHVM, see SECURITY.md.
FAQ
Our user FAQ has answers to many common questions about HHVM, from general questions to questions geared towards those that want to use.
There is also a FAQ for contributors to HHVM.
Hphp Doc Hackers Guide README (`hphp/doc/hackers-guide/README.md`)
Hacker's Guide to HHVM
This directory contains documentation on the internal architecture of HHVM,
targeted at C++ developers looking to hack on HHVM itself. If you're a
Hack developer looking for documentation on using HHVM, that can be found
here.
HHVM is a virtual
machine
that executes Hack programs using a bytecode
interpreter
and a JIT compiler
(the latter is vastly more complex, and will get much more airtime here).
PHP is also currently supported for historical reasons.
You should already be comfortable reading and writing C++ (specifically, HHVM is
written in C++14), as well as
navigating around large codebases using
grep,
ctags, or any similar tool of your
choice. Prior experience with compilers and runtimes will help but is not
strictly necessary.
Code References
Since this guide is intended to help familiarize readers with the HHVM codebase,
it naturally contains a number of links to the code. Some of these links are to
the current version of a file, and others are to specific lines in specific
versions of files. If you find links of either kind that are out-of-date with
current master or are otherwise misleading, please let us know.
Building HHVM
Instructions for building HHVM and running our primary test suite can be found
here. The
initial build may take an hour or longer, depending on how fast your machine is.
Subsequent builds should be faster, as long as you're not touching core header
files.
Architecture Overview
HHVM, like most compilers, is best thought of as a pipeline with many different
stages. Source code goes in one end, and after a number of different
transformations, executable machine code comes out the other end. Some stages
are optional, controlled by runtime options. Each stage is described in detail
in the articles listed below, but here's a quick-and-dirty overview:
Source code enters the compiler frontend and is converted to a token stream by
the lexer. The
parser reads this
token stream and converts that to an abstract syntax
tree, or AST. This AST is
then converted to a stream of bytecode
instructions by the bytecode emitter.
Everything up to this point is written in OCaml; the rest of HHVM is written in
C++ and assembly.
After the bytecode and associated metadata are created, our bytecode optimizer,
HHBBC, is optionally run. Finally, the bytecode, optimized or not, is stored
into a .hhbc file we call a "repo".
If the frontend was invoked directly by HHVM, the bytecode also lives in-memory
in the hhvm process, and execution can begin right away. If the frontend was
invoked as an ahead-of-time build step, the bytecode will be loaded from the
repo by hhvm when it eventually starts. If the JIT is disabled, the bytecode
interpreter steps through the code one instruction at a time, decoding and
executing each one. Otherwise, the JIT is tasked with compiling the bytecode
into machine code.
The first step in the JIT is region selection, which decides how many bytecode
instructions to compile at once, based on a number of complicated heuristics.
The chosen bytecode region is then lowered to HHIR, our primary intermediate
representation. A
series of optimizations are run on the HHIR program, then it is lowered into
vasm, our low-level IR. More optimizations are run on the vasm program, followed
by register allocation, and finally, code generation. Once various metadata is
recorded and the code is relocated to the appropriate place, it is ready to be
executed.
Getting Started
If you're not sure where to start, skimming these articles is a good first step:
HHVM Internals
The articles in this section go into more detail about their respective
components:
- HHBC spec
- Frontend
- Parser
- Emitter
- HHBBC
- ...
- VM Runtime
- Core data structures
- Memory management
- Execution Context
- Bytecode interpreter
- Unwinder
- Treadmill
- Debugger
- ...
- JIT Compiler
Hphp Doc Hands On Index (`hphp/doc/hands-on/index.md`)
Welcome to HHVM! This section of our documentation is a hands-on guide for
people who want to learn about compiler and runtime internals. The goal is to
work through making, testing, and measuring a sample optimization to our
compiler. You'll make the most out of this guide if you work through the
exercises as you read it.
Lesson 0: Building and running HHVM
Lesson 1: Understanding Hack runtime types
Lesson 2: Understanding Hack bytecode
Lesson 3: Refcounting and value types
Lesson 4: Making an IR optimization
Hphp Hack Doc HIPs Shape Poly Splat Impl Overview (`hphp/hack/doc/HIPs/shape_poly_splat/impl/overview.md`)
Splat: merge algebra, subtyping, and inference
A design document for the impl/ OCaml engine covering the three key additions:
- the merge algebra (how spread collapses an ordered list of rows into one row),
- the subtyping rules (with a long section on the type-parameter cases), and
- inference (how spread variables and unification variables are solved).
File and line references point at the OCaml under impl/lib and the tests underimpl/test/test_splat.ml. The core is three files: repr.ml (the representation and
the algebra), splat.ml (subtyping and inference), and env.ml (variable environments
and solving). prop.ml is the residual-constraint tree.
Throughout, "row" is a separate syntactic kind that is never exposed to a source
program. A row becomes a type only through shape, and a type re-enters row position
only through spread. This mirrors Hack: a shape(...) type is a row lifted into a
type, and spreading a shape (shape(...$s, 'k' => v)) is the destructor.
1. Representation
All types live in repr.ml. There are four levels: base types, rows, field
descriptors, and requiredness.
1.1 Base types
repr.ml:27-34:
base =
| Top -- ⊤
| Bottom -- ⊥
| Prim of Prim.t -- Bool | Nat (stand-ins for scalar types)
| Union of base * base -- first-class union types
| Shape of row -- a row lifted into a type (Hack's shape(...))
| Flex of Ty_var.t -- an inference variable (unification variable)
| Rigid of Ty_param.t -- a bounded generic parameter (Hack's `T as ...`)Hack mapping: Prim stands for the scalar leaves, Union is Hack's union type,Shape is shape(...), Rigid is a generic type parameter with bounds, and Flex
is an inference type variable (Tvar). Ty_param.t is just a name string
(ty_param.ml:7); Ty_var.t is an integer id (ty_var.ml:3).
1.2 Rows
repr.ml:37-57:
row =
| Row_simple of row_simple -- a concrete record shape
| Row_splat of row_splat -- an ordered list of things to merge
row_simple = { known : field_desc Label.Map.t ; unknown : base }
row_splat = { elems : row_splat_elem list }
row_splat_elem = Spread of baseA simple row is a finite map from known labels to field descriptors, plus anunknown base standing for every label not in the map. The unknown is always the
type of an optional absent label. This is the important subtlety: a simple row does
not store a full field descriptor for the tail, only a base, because a required unknown
field would demand that every absent label be present, which is uninhabited. So the tail
projects to Field_desc.opt unknown (repr.ml:626-630). This is exactly Hack's
open-shape tail (shape(..., ...)): unknown = Top is a fully open shape, unknown = Bottom is a closed shape.
A splat is an ordered list of spread elements. Each element is Spread base. If the
base is Shape (Row_simple s) the element is an inline row; if it is Rigid a it is a
spread of a generic parameter; if it is Bottom it is the bottom-row contribution
(repr.ml:55-57). Order matters: merge is rightmost-wins (Section 2).
1.3 Field descriptors and requiredness
repr.ml:64-72:
field_desc = { req : field_req ; base : base }
field_req = Req | OptA field descriptor is a product of two lattices:
- requiredness is a presence lower bound.
Reqmeans the field is definitely
present;Optmeans it may be absent. The order isReq < OptwithReqas bottom
andOptas top (repr.ml:75-88,field_req.mlcounterpart inField_req,repr.ml:438-463). - base is a type upper bound: the largest type the field's value can have.
Requiredness subtyping: a subfield must be at least as present as the superfield, sosub.req <= super.req, i.e. Req <= Opt (Field_req.lteq, repr.ml:449-454). A
required field can be supplied where an optional one is expected, not the reverse. In
Hack terms, a required shape field is a subtype of the same field marked optional.
The pairing of presence and type as two independent coordinates is the key design
choice. TypeScript conflates "optional" with "| undefined", which is a known bug
source; keeping presence separate from type makes merge a clean product operation
(Section 2.3) and makes the corner reasoning for parameters clean (Section 4).
1.4 Distinguished rows
Row.Simple.top(repr.ml:596): all fields optional atTop. The largest row.Row.Simple.empty(repr.ml:601): no known fields,unknown = Bottom. This is the
merge identity, not bottom. It is not uninhabited, becauseOpt Bottomis
satisfied by an absent field.Row.bot(repr.ml:845): the bottom row, represented asRow_splat [Spread Bottom].
It shape-wraps toBase.Bottom.
Note the asymmetry: a simple row is never bottom (is_bot_row, repr.ml:229-232);
bottom-ness lives only in Base.Bottom and in a splat that carries a Spread Bottom.
2. The merge algebra
Merge is the semantic content of spread: it collapses a splat (an ordered list of rows)
into one simple row, rightmost-wins and non-commutative. It is a monoid withRow.Simple.empty as identity.
2.1 Requiredness meet and join
repr.ml:75-88. Two-point lattice, Req bottom, Opt top:
meet_field_req:Reqif either isReq, elseOpt.join_field_req:Optif either isOpt, elseReq.
These are exercised exhaustively (only four inputs) in field_req_tests
(test_splat.ml:592-624).
2.2 Union, meet, join on base types
Union (union_base, repr.ml:105-111) is the join for base types. It is kept as a
first-class Union constructor and left-associated so that canonicalization is
idempotent. Crucially Splat does not distribute a union into a shape:Shape r1 | Shape r2 is never rewritten to Shape (r1 join r2). That rewrite is only
denotation-preserving when the two rows differ in at most one field; with two or more
differing fields it invents cross terms and becomes unsound. The long comment atrepr.ml:90-104 gives the counterexample{x:Bool,y:Bool} | {x:Nat,y:Nat} versus {x:Bool|Nat, y:Bool|Nat}. Keeping unions
first-class is why join never needs to distribute.
Meet (meet_base, repr.ml:113-126): there are no intersection types, so meet
distributes structurally: Bottom absorbs, Top is identity, two shapes meet
field-wise (meet_row), a union meets by the distributive law(a|b) & t = (a&t)|(b&t), equal prims meet to themselves, unequal prims and a
prim-vs-shape meet to Bottom. Meeting with a Flex or Rigid leaf is unrepresentable
(no intersection type is available to express it) and raises; the caller must solve or
instantiate the variable first.
Meet and join of rows (meet_row_simple, join_row_simple, repr.ml:142-182):
pointwise over labels using Label.Map.merge, with an absent label filled in from the
row's unknown at Opt. Both are only defined on simple rows; a splat that still
carries a variable or parameter has no single-row meet or join and raises. Callers
canonicalize (which resolves solved splats to simple rows) first.
Base.meet and Base.join (repr.ml:406-416) canonicalize inputs and output, because
meeting two shapes can produce an uninhabited shape (a field meets to Req Bottom) that
must collapse to Bottom to keep is_bot correct.
These laws are checked as lattice properties against a brute oracle in algebra_props
(test_splat.ml:655-808): commutativity, associativity, idempotence, absorption,
identities, and "meet is the greatest lower bound" / "join is the least upper bound".
2.3 Merge on field descriptors (rightmost-wins)
This is the heart of spread. merge_field_desc (repr.ml:184-191):
merge ~left ~right =
match right.req with
| Req -> right -- required right overrides fully
| Opt -> { req = meet left.req Opt ; base = union left.base right.base }Read it as: the right operand is the later spread. If the right field is required,
it completely overrides the left (it is definitely present with its own type). If the
right field is optional, the merged field's presence follows the left field
(meet left.req Opt = left.req) and its type is the union of both, because at
runtime the value is either the left one (right absent) or the right one (right present).
This is structurally identical to TypeScript's object-spread rule and to Hack's shape
spread: a required later key wins, an optional later key unions with the earlier type and
keeps the earlier presence. Because presence is a separate coordinate here, there is no
need for TypeScript's \ undefined workaround.
Row.Simple.empty (Opt Bottom everywhere) is the two-sided identity: merging it in
changes nothing (meet left.req Opt = left.req, union base Bottom = base). Verified asField_desc.merge identity (opt bottom) and Simple.merge identity (empty row)
(test_splat.ml:735-807).
2.4 Merge on simple rows
merge_row_simple (repr.ml:193-209): merge the known maps label-wise withmerge_field_desc, filling an absent label from that side's unknown at Opt, and set
the merged unknown to the union of the two unknown bases (both are optional, so the
union of bases is right).
2.5 Projection through a splat
Merging an entire splat is done lazily, one label at a time, by Splat.proj
(repr.ml:792-816). To project label l:
- Start from the rightmost element's field at
l(Splat_elem.proj,repr.ml:682-690: a rigid parameter reads its corner assignment from the passedfld_assignmentmap;BottomyieldsReq Bottom; an inline simple row projects
normally). - Walk leftward, folding with
merge_field_desc ~left ~right. - Short-circuit on
Req(proj_help,repr.ml:792-804): once the accumulated
field isReq, everything further left is masked and cannot contribute, so stop.
The None label case projects the tail (unknown), always optional.
2.6 Canonicalization
canon_base / canon_row / canon_row_splat (repr.ml:284-347) put a type into a
normal form so that predicates like is_bot and structural equality are meaningful. The
important moves:
- A
Shapewhose row is bottom collapses toBottom(repr.ml:288-290). - A splat with a single concrete spread collapses to that simple row
(repr.ml:299-305,row_splatatrepr.ml:260-267). - A
Spread (Shape (Row_splat ...))is flattened: the inner splat's elements are
spliced into the parent (collapse_spread,repr.ml:312-316). - Adjacent concrete spreads are eagerly merged (
canon_row_splat_help,repr.ml:336-347, usingmerge_splat_elem_opt,repr.ml:250-258). Two spreads merge
only when both are concrete inline rows; an opaqueRigid/Flexspread or aSpread Bottomblocks the merge and stays a separate element.
Canonicalization is idempotent and denotation-preserving, checked intest_splat.ml:756-765 and (for the new spread paths) canon_spread_tests
(test_splat.ml:1210-1233). After canon, "is this row bottom" is decidable by inspecting
for a Spread Bottom (is_bot_row, repr.ml:229-232).
tell_subtype / tell_subrow (splat.ml:955-970) canonicalize their inputs once at the
top; the recursion below assumes it only ever sees canonical (flat) splats.
3. Subtyping: base types
subtype_base ~sub ~super env (splat.ml:169-223) returns an updated env and a residualProp.t (a proposition over the remaining flex variables; Section 5). When there are no
flex variables the residual is either valid or invalid.
Rule order matters. The comment at splat.ml:169-174 fixes it. The cases, in order:
Bottom <: _is valid;_ <: Topis valid (splat.ml:176-179).- Any
Flexon either side records a residual atom and stops (splat.ml:181). A flex
sub against a union is recorded as a single upper bound, not split into a
disjunction (splitting would be incomplete). Solving happens later, in dispatch. - A sub-union is conjunctive and exact:
(a|b) <: tiffa <: tandb <: t
(splat.ml:183-186). - A super-union distributes disjunctively:
t <: (a|b)becomest <: aort <: b
(splat.ml:187-190). This is checked after the sub-union and flex cases so that the
disjunction never hides a variable that should have been recorded. - Rigid parameters (F-sub): reflexivity, then reduce through the bound
(Section 4.1). - Eager ground rejections:
_ <: Bottomfor non-bottom,Top <: _for non-top, prim
mismatches, prim-vs-shape (splat.ml:204-221). Shape sub <: Shape superreduces tosubrow(splat.ml:223).
The ground fragment of this relation is checked to be sound and complete against an
independent brute oracle (gsub) over 25000 random pairs
(ground differential: check = brute, test_splat.ml:449-455), plus reflexivity,
bottom-least, top-greatest, transitivity, and the lattice laws.
4. Subtyping: the type-parameter cases
This section is the focus. It covers two mechanisms: the F-sub rule for a parameter
at base position, and the corner decision procedure for parameters spread into a row.
4.1 F-sub: a rigid parameter at base position
splat.ml:191-223. A Rigid a is a generic with an interval [lower, upper] of base
types recorded in env.ty_param (Env.Ty_param_env, env.ml:4-80). The rule:
Rigid a <: Rigid bwitha = b: reflexivity, valid (splat.ml:197).Rigid a <: T: reduce througha's upper bound:a <: upper(a) <: T, so checkupper(a) <: T(splat.ml:198-200).T <: Rigid b: reduce throughb's lower bound:T <: lower(b) <: b, so checkT <: lower(b)(splat.ml:201-203).Rigid a <: Rigid bdistinct: chains both, reducing toupper(a) <: lower(b).
This is standard bounded-quantification F-sub. Reflexivity is tried first so a parameter
whose bounds are loose is still a subtype of itself. Tests: fsub_tests
(test_splat.ml:824-838), including the distinct-parameter chain.
Hack already implements exactly this for generic parameters with as/super bounds. The
only new thing is that a parameter can also appear at spread position, which is the
next subsection.
4.2 Why parameters spread into a row need corners
Consider a subrow query where a parameter is spread into one or both sides:
|{x: Req Bool}, ...T| <: |...T| with T in [ {x: Req Nat} , {x: Opt Top} ]For this to hold it must hold for every instantiation of T inside its bound
interval. T's field at x is a field descriptor somewhere in the box between the
lower bound Req Nat and the upper bound Opt Top. Because the field descriptor is a
product of two lattices (requiredness and type), that box has up to four corners:
1) Req Nat (lower bound)
2) Opt Nat (top requiredness, bottom type)
3) Req Top (bottom requiredness, top type)
4) Opt Top (upper bound)The worked example in repr.ml:513-568 shows that checking only the two bounds (corners
1 and 4) reaches the wrong answer: both bounds pass, but corner 2 (Opt Nat) makes the
proposition false. So in general all four corners of the box must be checked.
The justification for corners being sufficient (rather than checking the whole
continuum) is monotonicity: <: is monotone in each field-descriptor coordinate, so the
worst case for L <: R sits at an extreme of each parameter's box. That is why a
parameter appearing on one side only collapses to a single corner (Section 4.5), and
a parameter on both sides needs the full box (up to four).
Field_desc.corners ~lower ~upper (repr.ml:569-577):
corners ~lower ~upper =
match lower.req, upper.req with
| Req, Opt -> [ lower ; upper ; {lower with Opt} ; {upper with Req} ] -- 4
| Req, Req | Opt, Opt -> [ lower ; upper ] -- 2
| Opt, Req -> [] -- ill-formedThe four-corner case only arises when the lower bound is Req and the upper is Opt
(a genuinely two-dimensional box). Tests: corners_tests (test_splat.ml:627-652) and
the property that every corner lies in the [meet, join] box
(test_splat.ml:744-754).
4.3 The per-label decision procedure
subrow_splat (splat.ml:248-260) reduces a splat-vs-splat (or splat-vs-simple) subrow
to a conjunction of per-label subfield checks, one per label in subrow_label_set
(Section 4.7). Each label is handled by subrow_splat_at (splat.ml:319-396), which is
the interesting procedure. At one label:
Restrict to live parameters (
splat.ml:322-324). Only parameters that could
still contribute to this label matter.Row.live_spread_at(repr.ml:773-790) walks
a splat from the right and collects rigid parameters that are not masked by aReq
field to their right. AReqinline field, or aSpread Bottom, masks everything to
its left (rightmost-wins). Parameters to the left of such a required field cannot
affect the merged field at this label, so they are dropped from the enumeration. This
is condition (2) in the design comment atsplat.ml:271-277.Topologically sort the live parameters (
splat.ml:326-332). Parameters can appear
in each other's bounds (a parameterQwhose bound spreadsP). The bound projection
of one parameter reads the corner assignment of the parameters in its bound, so those
must be assigned first.Env.Ty_param_env.closure(env.ml:46-56) pulls in every
parameter reachable through bounds (so a parameter that lives only inside another's
bound is included), andEnv.Ty_param_env.topo(env.ml:58-79) orders them so
dependencies come first.Compute the dependency set
depended_on(splat.ml:335-339): parameters that
appear in some other parameter's bound. A parameter not in this set is "free": its
assignment does not feed any other parameter's projection, so its corners can often be
pruned (Section 4.5).Enumerate corner assignments in topological order (
loop_params/loop_assignments,splat.ml:365-395). For each parameter,corners_for
(Section 4.5) returns the corner field descriptors to try; the loop takes the product
over all live parameters, extending thefld_assignment : field_desc Ty_param.Map.t
incrementally. Because the order is topological, when a parameter's corners are
computed the parameters in its bound already have assignments in the map.At a complete assignment, project both rows at the label with that assignment
(Row.proj, which runs the merge of Section 2.5 with the parameters resolved to their
corner fields) and emit the subfield check (splat.ml:377-382,subfieldatsplat.ml:863-874).
The whole thing is a conjunction: the subrow holds iff the subfield check passes at every
label and every corner assignment. This is co-assignment exponential in the number of
live, dependent parameters, but in practice the count is small and the shortcuts below
prune it hard.
4.4 Field bounds of a parameter at a label
Row_help.field_bounds (splat.ml:67-77): to get a parameter's [lower, upper] box at
a label, read the parameter's bound shapes from env.ty_param, take the row of each
bound (row_of_base, splat.ml:23-29: a shape's row, or the bottom row for Bottom),
and project each at the label under the current partial assignment (so a parameter whose
bound itself spreads another parameter is resolved through that parameter's already-fixed
corner). The result is the field-descriptor interval whose corners drive the enumeration.
4.5 The corner shortcuts (`corners_for`)
corners_for (splat.ml:342-361) applies the four optimizations described in the design
comment at splat.ml:280-317. Let a parameter be free (not in depended_on),in_sub (live in the sub-row at this label), in_super (live in the super-row):
- (i) sub side only (
free && in_sub && not in_super): use[upper]only. A
parameter appearing only in the sub-row contributes only to the left of<:; the
hardest case is the one that maximizes the sub-row, which is the parameter's upper
bound. - (i) super side only (
free && not in_sub && in_super): use[lower]only.
Symmetric: the hardest case minimizes the super-row, the lower bound. - both sides (
free && in_sub && in_super): look at masking (Section 4.6):- (ii) masked in sub (
masking_sub = Masked): the parameter cannot contribute to
the sub-row (something to its right always overrides it there), so it acts as a
super-only parameter: use[lower]. - (iii) masked in super (
masking_super = Masked): symmetric, super side is fixed,
acts as sub-only: use[upper]. - (iv) unmasked in super with optional lower (
masking_super = Unmaskedandlower.req = Opt): use[lower]. When the parameter can never be masked on the super
side and its lower requiredness is alreadyOpt, the field's presence is settled
(the merge staysOpt), so only the type matters, and the type that is hardest for<:is the one that grows the super side least, the lower bound. The proposition has
the shape(U | t) <: (V | t)wheretis this field's type, and a freetis
worst-cased at its lower bound. - otherwise: the full
corners ~lower ~upper(up to four).
- (ii) masked in sub (
- not free: the full
corners. A depended-on parameter cannot be pruned, because its
choice changes another parameter's projected bounds.
These shortcuts are what keep the enumeration cheap: a parameter that occurs on only one
side, or is masked, collapses from four corners to one.
4.6 Masking
Row_help.masking_of / masking_of_splat (splat.ml:99-165). At a label and a partial
assignment, a parameter within a row is:
- Masked: some parameter to its right is
Reqin its upper bound at this label,
so that rightward parameter always supplies the field and overrides everything to the
left. The target cannot contribute. - Unmasked: every parameter to its right is
Optin its lower bound, so nothing
to the right can override it. - Unknown: neither holds (some rightward parameter has
Reqlower butOptupper, so
we cannot tell), or the target does not occur in this row. Unknown is the pessimistic
answer and blocks the single-corner shortcuts, falling back to full corners.
The key subtlety (splat.ml:133-144): whether a rightward parameter r masks the target
is decided by r's own bounds at this label, not the target's. If r's upper isReq it masks; if r's lower is Req but upper Opt the answer is Unknown; if r isOpt at both bounds it does not mask. This is regression-tested bymasking: rightward param's bounds decide (test_splat.ml:970-976) and its mirror
(test_splat.ml:980-988).
4.7 Discovering labels that live only in bounds
Row.label_set of a splat of bare parameter spreads is empty (a Spread (Rigid a) has
no inline labels). But a label can live only inside a parameter's bound, and it is still
a real per-field obligation. Row_help.bound_label_set (splat.ml:38-51) collects the
labels of every bound of every parameter mentioned, taking the transitive closure so a
label buried in a nested parameter's bound is found too. subrow_label_set
(splat.ml:55-60) unions the inline labels of both rows with these bound labels, and the
per-label loop visits all of them plus the None (unknown-tail) pseudo-label
(subrow_labels, splat.ml:62-64). Missing this would silently skip a field and be
unsound; tested by bound-only label is discovered (test_splat.ml:989-993).
4.8 Nested parameters
A parameter Q whose bound spreads P (so P is reachable only through Q's bound) is
"the hard case". The transitive closure (Section 4.3 step 2) pulls P into the
enumeration, topo orders P before Q, and field_bounds projects Q's bound withP already assigned to a corner. Without the closure the engine would raise. This is
covered by nested_tests and the nested-param differential property over 20000 cases
(test_splat.ml:1120-1158), whose oracle universally quantifies P with Q determined
by P.
4.9 The differential oracle for parameters
rigid-param differential: check = forall-instantiation (test_splat.ml:1023-1035,
generator at test_splat.ml:840-932) is the strongest correctness check on the corner
machinery. It places parameters P, Q at spread position with random shape-bound
boxes, then compares the engine's decision against a true denotational oracle that
enumerates every instantiation of P and Q within their boxes and checks the concrete
subrow at each. This exercises corner enumeration, the masking shortcut, bound-label
discovery, and single-side drops, over 20000 random cases. The engine agrees exactly:
the corner procedure decides the semantic (universally quantified) subrow.
5. Subtyping: simple rows
subrow_simple (splat.ml:833-856) is the ground case: both sides are concrete simple
rows. It checks the subfield relation at every known label of either side plus the None
tail. subfield (splat.ml:863-874): a subfield holds iff sub.req <= super.req
(presence) and sub.base <: super.base (type), the two coordinates checked
independently. Projection at an absent label uses the row's unknown at Opt
(Simple.proj, repr.ml:626-630).
This is the same shape-subtyping Hack already does for shape(...) types: required beats
optional, field types are checked covariantly, and the open tail governs unknown fields.
6. Inference
Inference has two independent axes: flex variables (unification variables at base
position) and spread variables (a flex variable spread into row position). Both are
driven through a residual proposition.
6.1 The residual proposition
prop.ml. subtype_base and friends do not decide immediately when a flex variable is
involved; they emit a Prop.t:
atom = Subtype_base { sub ; super } | Subfield { label ; sub ; super }
t = Atom of atom | Conj of t list | Disj of { failures ; props }valid = Conj []; invalid f = Disj { failures = [f]; props = [] }. Smart constructorsconj / disj (prop.ml:41-81) keep the tree normalized: conj drops valids and folds
nested conjunctions, disj collects alternatives. is_invalid is "a Disj with no
surviving alternatives" (prop.ml:29-33); is_valid is "the empty Conj". A query is
accepted when its final proposition is not invalid (test_splat.ml:16-18).
6.2 Flex variables and dispatch
After building the residual, tell_prop (splat.ml:883-952) walks it, records each
variable constraint in the env, and for every genuinely new bound generates the implied
transitive constraint and dispatches that in turn. The recursion terminates because
implications are generated only for newly recorded bounds.
tell_atom_base(splat.ml:921-929): aFlex <: Flexrecords an upper on the left
variable and a lower on the right; a one-sided flex records one bound; a ground atom is
re-checked bysubtype_base.record_ty_upper(splat.ml:931-940): addsuperas an upper bound ofvar_sub; if
new, for each existing lower boundlof the variable emitl <: super(transitivity
through the variable) and dispatch.record_ty_lower(splat.ml:942-952): the mirror.
Bounds are stored per variable in Env.Ty_var_env (env.ml:82-110) as lists of uppers
and lowers. This is the standard "constraint graph, propagate on new edge" approach and
matches how Hack's Typing_subtype records Tvar bounds and closes them transitively.
The transitive-closure behaviour is checked in flex transitive closure
(test_splat.ml:427-431) and the full soundness-and-completeness closure over 25000
cases in flex differential: check = exists-witness (test_splat.ml:483-493).
6.3 Solving a flex variable
Env.solve_ty_var (env.ml:143-152): a variable's current solution is read only from
bounds already discharged into the env, never from undischarged residual atoms. With
lower bounds present, the solution is the join of the lowers (the least value satisfying
them); with only uppers, the meet of the uppers; with neither, Bottom. Bounds are
resolved recursively before combining (resolve_base, env.ml:207-214), so meet and
join only see ground types. Termination relies on the bound graph being acyclic. The
recovered solution is checked to be a real witness in solution is a witness
(test_splat.ml:497-507).
6.4 Spread variables: overview
A spread variable is a flex variable in spread position (Spread (Flex v)), standing for
an unknown row to be merged in. The dispatch in subrow (splat.ml:226-241) branches on
which sides carry spread variables:
- neither side: ground splat, go to
subrow_splat(Section 4). - sub side only:
subrow_infer_sub(Section 6.5). - super side only:
subrow_infer_super(Section 6.6). - both sides:
subrow_infer_sub_super(Section 6.7).
For inference we want at most one spread variable per side; topo_spread_vars
(repr.ml:737-748) picks an order and rightmost_spread_var (repr.ml:759-768) is the
one to retain when eliminating (Section 6.8).
6.5 A spread variable in the sub-row
subrow_infer_sub / subrow_infer_sub_part / subrow_infer_sub_part_at
(splat.ml:402-582). For a variable ?p in the sub-row, partition the sub-row at ?p
into pre (left of ?p) and post (right of ?p). Resolve every other variable to
its current solution (solve_all_except, splat.ml:11-15), leaving ?p symbolic. Then,
per label, build the field descriptor that ?p must have and accumulate a residual
constraint ?p <: shape({those fields}) (splat.ml:459-463).
At a single label (subrow_infer_sub_part_at, splat.ml:475-582) the logic mirrors the
merge:
- If
postrequires the field at every corner assignment,?pis overwritten
everywhere (masked to its right), so its upper bound isTopand we just checkpost <: superper assignment. - Otherwise
?pcontributes. Its requiredness is forced toReqwhen the field isReq
in super andprecannot supply it, or whenpre's type does not fit super so?p
must beReqto mask the offendingprefield. This is themust_reqdisjunction atsplat.ml:522-546. The merged sub type at this label is the union of the fresh variable
for?p,post, and (unless?pisReq)pre, checked against super.
The unknown-tail label (None) never forces Req, because a required unknown is
unrepresentable (splat.ml:526-531).
The sub-side masking fix at splat.ml:536-545 (force ?p to Req to mask a
type-violating neighbour) is what makes the single-row-variable relation exactly complete;
without it there was a residual over-reject. The round trip is checked inrow differential (1 var): check_row = exists-witness over 20000 cases
(test_splat.ml:508-520) and row solving: recovered solution is a witness
(test_splat.ml:527-538).
6.6 A spread variable in the super-row
subrow_infer_super and helpers (splat.ml:585-765) are the dual. For ?p in the
super-row, per label build the field ?p must have as a lower bound and accumulateshape({those fields}) <: ?p. The requiredness of ?p's field mirrors the sub-row: Opt
if some live sub field is Opt (the merge must then stay Opt), else Req. An
unsatisfiable case is a sub field that is Opt while super pre is Req (the merge is
forced Req, and Opt is not <= Req), reported invalid at splat.ml:731-743.
6.7 Spread variables on both sides
subrow_infer_sub_super (splat.ml:805-829):
- Exactly one variable on each side: couple them through a fresh intermediary row
mid(subrow_infer_couple,splat.ml:779-803). Build a fresh field per label inmid, checksub <: mid(which bounds?p_subfrom above) andmid <: super(which
bounds?p_superfrom below);mid's fresh variables link the two so the sub side's
contribution flows through to the super side. Sound by transitivitysub <: mid <: super.mid's known fields are fixedReq(measured to over-reject
much less thanOpt), and its unknown is a fresh opt-only base. This keeps the
cross-side coupling that the naive elimination would drop. - More than one variable on a side: fall back to the decoupled solver
(splat.ml:816-829). Resolve the other side's variables to their current solutions
(an unsolved one becomes the empty row) to make it concrete, then solve this side
against it. Sound, but the cross-side coupling is dropped, so this over-rejects. The
two-variable case is checked sound-only inrow differential (2 vars): SOUND (accept implies witness)(test_splat.ml:539-549),
and theDIAGharness (test_splat.ml:1309-1377) quantifies the over-reject rate.
6.8 Resolving a spread variable to a row
Env.resolve_spread_var (env.ml:170-196): a spread variable resolves to its current
solution as a row. The bounds are combined at the row level: Row.join of the
lower shapes (covariant) or Row.meet of the upper shapes (contravariant), not viaBase.join / Base.meet. This matters because base join does not distribute into shapes,
so combining two lower shapes with Base.join would produce an ill-formedSpread (Union (Shape, Shape)); the row join is the correct single-row bound. An unbounded
spread variable resolves to the empty row (the merge identity, contributing nothing),
not bottom. Tested by spread var with 2 distinct lowers stays a single row
(test_splat.ml:996-1019).
resolve_row / resolve_base (env.ml:156-214) substitute every flex variable by its
current solution recursively, with an optional except to leave the one variable being
solved untouched.
7. Known incompleteness (deliberate)
These are the facts the engine drops to stay sound and decidable. Each is documented in
the source with a counterexample.
No shape/union distribution (
repr.ml:90-104).Shape r1 | Shape r2is never
folded toShape (r1 join r2). Sound only when the rows differ in at most one field;
otherwise it invents cross terms. Consequence:{x:Bool} | {x:Nat}is not recognized
as a subtype of{x: Bool|Nat}.Both-sides spread coupling (
splat.ml:769-778). With one spread variable on each
side the intermediarymidfixes its known fields toReq, which over-rejects some
satisfiable queries (the "keep-both requiredness" gap). With more than one variable on
a side the coupling is dropped entirely (Section 6.7). Both are sound.Meet with a variable or parameter raises (
repr.ml:122-125). There are no
intersection types, so an unrepresentable meet is a hard error; the caller must solve
or instantiate first.
None of these affect soundness. The ground and single-spread-variable fragments are
complete (Sections 4.9, 6.5); the multi-variable both-sides fragment is sound only.
8. Test strategy
The tests are the specification of correctness and are worth porting as differentials:
- Ground differential (
test_splat.ml:449-455): engine vs brute per-field oraclegsub, 25000 cases. Sound and complete on ground types. - Flex differential (
test_splat.ml:483-493): a variable query is accepted iff some
chain assignment satisfies it, 25000 cases. - Solution is a witness (
test_splat.ml:497-507): the solved value really satisfies
the query. - Row differential, 1 var (
test_splat.ml:508-520): the single-spread-variable
relation is a full iff. - Rigid-param differential (
test_splat.ml:1023-1035): the corner procedure vs a
universally quantified denotational oracle over parameter instantiations, 20000 cases.
This is the strongest check on the type-parameter machinery. - Nested-param differential (
test_splat.ml:1146-1158): the transitive closure case. - Row differential, 2 vars (
test_splat.ml:539-549): sound only; theDIAGharness
quantifies the over-reject. - Algebra laws (
test_splat.ml:655-808): merge, meet, join, canon, corners against
the lattice laws and the brute order oracle.
The pattern to reuse: an independent brute oracle that enumerates the finite witness
universe (ground types, or all instantiations of a bounded parameter) and acheck = oracle differential. That is what ties the corner procedure to the semantic
subrow relation.
Hphp Doc Coding Conventions (`hphp/doc/coding-conventions.md`)
HHVM Coding Conventions
This document is meant to serve as a guide to writing C++ in the HHVM codebase,
covering when and how to use various language features as well as how code
should be formatted. Our goal is to ensure a consistently high-quality codebase
that is easy to read and contribute to, especially for newcomers.
The HHVM codebase contains a wide variety of code from many different authors.
It's been through a few different major stages in its life, including stints in
multiple different repositories. As a result, large (primarily older) parts of
the codebase do not fit this guide. When in doubt about how to write or format
something, always prefer the advice here over existing conventions in the
code. If you're already touching some older code as part of your work, please
do clean it up as you go along. But please do not spend hours applying the
formatting guidelines here to code you aren't otherwise modifying. While we'd
love for the entire codebase to follow this guide, we'd rather get there
gradually than lose lots of git history and developer time to purely cosmetic
changes. That said, if cosmetic changes that you're making as part of a larger
diff keep growing in scope, it may be worth pulling them out into a separate
diff.
There's no well-defined cutoff here - just try to minimize effort for your
reviewers. A good rule of thumb is that if your cosmetic changes require adding
significant new sections to the diff (such as a function rename that touches
all callsites), it should probably be pulled out into its own diff.
Headers
Every .cpp file in the HHVM repository should have a corresponding .h file with
the same name, and which declares its public interfaces. We tend to value API
documentation more heavily than inline implementation comments, so all
declarations in headers (classes, enums, functions, constants, etc.) should be
documented. See Comments and Documentation for more details.
Build times are a frequent source of pain in many large C++ projects. Try not
to make large header files that mostly serve to include groups of other large
header files. This can discourage "include what you use," discussed in the
"What to include section".
Include guards
To prevent multiple inclusion, all headers should have the following directive
after their license header comment:
/*
* ...see the 'File copyright' section for details on what goes here...
*/
#pragma once
// File contentsWhat to include
The golden rule for what to include is "include what you use" (IWYU). In brief,
this means you should not rely on any headers you include to transitively
include other headers which have definitions you require. You should also
prefer to forward declare structs and classes when the definition is not needed
(so, "don't include what you don't use"), which helps reduce HHVM's nontrivial
build time.
To make it easier to achieve IWYU, we have the following guidelines for
includes:
- Always include the corresponding .h for a .cpp first, before even system
headers. - Separate includes into groups: C++ standard library headers, external projects
(such as Boost and Intel TBB), and finally headers within HHVM. Each group
should be separated by a newline, for readability. (Whether to separate HHVM
includes by subsystem (e.g.,jit) is left up to the author.) - Keep headers alphabetized within each group. This makes it easier to ensure
that all necessary includes are made, and no extraneous ones are left behind. - Use double quotes for Folly and HHVM headers and angle brackets for all
others.
As an example, here is what the include section might look like for a file
named bytecode.cpp:
#include "hphp/runtime/vm/bytecode.h"
#include <cstdio>
#include <string>
#include <boost/program_options/options_description.hpp>
#include "hphp/runtime/vm/class.h"
#include "hphp/runtime/vm/func.h"
#include "hphp/runtime/vm/hhbc.h"
#include "hphp/util/string.h"Inline functions
Defining functions inline is encouraged for very short functions.
When defining inline member functions on structs or classes which have tight,
compact interfaces (e.g., a smart pointer class, or any wrapper class), prefer
to define the functions in the class definition, for concision.
However, for classes with more complex, malleable APIs where inline helpers
proliferate (e.g., Func, Class, IRInstruction, etc.), restrict the class
definition to member function prototypes only. This makes the API much
cleaner. For these classes, define all inline functions in a corresponding-inl.h file.
// At the bottom of func.h.
#include "hphp/runtime/vm/func-inl.h"// After the copyright in func-inl.h.
namespace HPHP {
// Definitions go here.
}For API's large enough to warrant -inl.h files, move all definitions into the
-inl.h, even one-line accessors. This serves both to keep the API cleaner and
to avoid splitting implementations among three files (the header, the inline,
and the source).
Some files, with or without a corresponding -inl.h file, may need a -defs.h
file. This file also contains definitions of inline functions, but it is not
included by the main header. It is intended to be used when only a few callers
need access to the definitions, or when the definitions can't be in the main
header because it would create circular dependencies. It should be included
directly by the callers that do need access to the definitions it contains.
Structs and Classes
Classes are used extensively throughout the HHVM codebase, with a number of
coding conventions. See also Naming for conventions around class naming.
Using struct vs. class
In C++, struct and class have nearly identical meanings; the only
difference lies in the default accessibility (struct defaults to public, andclass, to private).
We do not assign further meaning to these keywords, so we use struct
everywhere. Efforts to compile under MSVC also require that we use the same
keyword between a struct/class definition and its forward declarations due to
MSVC's failure to adhere to the C++ spec, and sticking to struct everywhere
makes this easier.
Access control
Try to avoid the protected keyword. It tends to give a false sense of
security about encapsulation: since anyone can inherit from your class, anyone
can access the protected member with a little extra effort.
Implicit and explicit constructors
By default, always use explicit for single-argument, non-initializer list
constructors.
struct MyStruct {
// We don't want to implicitly convert ints to MyStructs
explicit MyStruct(int foo);
// Two-argument constructor; no need for explicit
MyStruct(const std::string& name, int age);
};Public data members vs. getters/setters
Prefer declaring public member variables to using getters and setters. Getters
and setters that don't manage object state in a nontrivial way serve to bloat
the API and introduce unnecessary boilerplate.
Getters are, of course, encouraged for private members. Avoid prefixing getters
with get:
struct Func {
const SVInfoVec& staticVars() const;
void setStaticVars(const SVInfoVec&);
ArFunction arFuncPtr() const;
static constexpr ptrdiff_t sharedBaseOff();
};Declaration order
Adhere to the following order for declarations in a struct or class definition:
- Friend classes.
- Nested classes, enums, typedefs. (If possible, just declare the nested
class and define it following the enclosing class definition.) - Constructors, destructor.
- Member functions, including static functions, documented and grouped
coherently. - Constants and static data members.
- All instance data members, regardless of accessibility.
Private member functions can be interspersed with public functions, or
relegated to a single section before the data members. However, all instance
properties must occur contiguously at the end of the class definition.
Other C++ Language Features
Very few language features are unconditionally banned. However, if you want to
use one of the more controversial constructs such as goto or operator,(),
you'd better have a convincing argument as to why it's better than the
alternatives. C++ is a very large and complex language and we don't want to
artificially limit what developers can do, but that puts a lot of
responsibility on your shoulders.
Avoiding restrictions on useful language features (e.g., exceptions, templates,
C++11 lambdas) is a major motivating factor for maintaining our own style guide
rather than adopting an existing one.
Namespaces
All HHVM code should be scoped in namespace HPHP { /* everything */ }. Large
submodules such as HPHP::jit and HPHP::rds may be contained in their own
namespace within HPHP. We often use anonymous namespaces instead of thestatic keyword to keep symbols internal to their translation unit. This is
mostly left up to the author; just keep in mind that classes and structs,
unlike functions and variables, must be in an anonymous namespace in order to
be properly hidden.
Avoid using namespace whenever possible, especially in headers. It is
acceptable in .cpp files in very limited scopes (function-level or deeper) if
it will significantly aid in readability of the code that follows. using namespace std; at the top of a .cpp is explicitly disallowed.
Enums
Prefer enum class whenever possible. Old-style enums are generally only
acceptable if you expect that your type will be frequently used in an integer
context, such as array indexing.
Naming
HHVM code adheres to the some broad naming conventions.
When the convention is left open, in general, prefer the local conventions
used in the file you are working on---e.g., in a struct whose data members all
have m_namesLikeThis, prefer m_anotherNameLikeThis to m_this_style, even
though the latter is found in other parts of the codebase.
Variables
Use lowerCamelCase or lower_case_with_underscores for all local variables,
adhering to whichever is the discernable local convention if possible. Static
variables (whether declared in an anonymous namespace or with the static
keyword) should additionally be prefixed by s (e.g., s_funcVec). Globals,
likewise, should be prefixed by g_ (e.g., g_context).
Constants
All constants should be prefixed with k and use CamelCase, e.g.,kInvalidHandle. Prefer constexpr to const whenever possible.
Class data members
As with variables, use lowerCamelCase or lower_case_with_underscores for
all data members. Additionally, private instance members should be prefixed
with m_ (e.g., m_cls, m_baseCls, m_base_cls), and all static members
should be prefixed with s_ (e.g., s_instance). Prefer to leave public
members unprefixed.
Functions
We generally prefer lowerCamelCase for header-exposed functions, including
member functions, although we use lower_case_with_underscores as well (e.g.,hphp_session_init), more commonly in file-local scopes. As usual, follow the
local naming conventions of the file you are working in.
If you are modeling a class after an existing pattern, such as an STL
container, prefer to follow the appropriate conventions (e.g.,my_list::push_back is preferred over my_list::pushBack).
Classes
Classes use UpperCamelCase, except when modeling existing patterns like STL
containers or smart pointers.
Namespaces
New namespaces should use lowercase---and single-word namespaces are greatly
prefered for common usage. For longer namespaces (e.g., vasm_detail), uselower_case_with_underscores.
Other conventions
Prefer correctly capitalizing acronyms in new code (e.g., prefer IRTranslator
to HhbcTranslator). In this vein, prefer ID (e.g., TransID) to Id
(e.g., FuncId) in new code.
Formatting
While consistent code formatting doesn't directly affect correctness, it makes
it easier to read and maintain. For this reason, we've come up with a set of
guidelines about how code should be formatted. There's a good chance that some
of these will conflict with your own personal preferred style, but we believe
that having a consistently easy to read codebase is more important than letting
each developer write code that he or she thinks is uncompromisingly beautiful.
Anything not specified here is left up to the judgment of the developer.
However, this document is not set in stone, so if a particular formatting issue
keeps coming up in code review it probably deserves a few lines in here.
General rules
- All indentation is to be done using spaces.
- Each indentation level is 2 spaces wide.
- Lines may be no longer than 80 characters, unless absolutely required for
some syntactic reason. - Lines should not have any trailing whitespace. This includes blank lines at
non-zero indentation levels; the only character on those lines should be a
newline.
Types and variables
- When declaring a variable or typedef, the
*and&characters for pointer
and reference types should be adjacent to the type, not the name (e.g.,const Func*& func). - Limit variable declarations to one per line.
Function signatures
The following function signatures are formatted properly:
// If arguments would fit on 1 line:
inline void Func::appendParam(bool ref, const Func::ParamInfo& info) {
}
// If the arguments need to wrap, we have two accepted styles, both of which
// are OK even if the wrapping wasn't necessary:
SSATmp* HhbcTranslator::ldClsPropAddr(Block* catchBlock,
SSATmp* ssaCls,
SSATmp* ssaName,
bool raise) {
doSomeStuff();
}
// This style is helpful if any of the function, argument, or type names
// involved are particularly long.
SSATmp* HhbcTranslator::ldClsPropAddr(
Block* catchBlock,
SSATmp* ssaCls,
SSATmp* ssaName,
bool raise
) {
doSomeStuff();
}Always keep the type on the same line as the function name, unless it would
leave insufficient room for arguments. Do likewise with other modifying
keywords (inline, static, any attributes).
Wrapped arguments should always be aligned with the argument on the previous
line. The opening curly brace should be on the same line as the last argument,
with the exception of class constructors (see the Constructor initializer list
section). When writing function declarations in headers, include argument names
unless they add no value:
struct Person {
// The single string argument here is obviously the name.
void setName(const std::string&);
// Two string arguments, so it's not obvious what each one is without names.
void setFavorites(const std::string& color, const std::string& animal);
};Statements
Conditional and loop statements should be formatted like so:
if (vmpc() == nullptr) {
fprintf(stderr, "whoops!\n");
std::abort();
}Note that there is a single space after the if keyword, no spaces betweencondition and the surrounding parentheses, and a single space between the )
and the {. As with all blocks, the body should be one indentation level
deeper than the if. If the entire statement (condition and body) fits on
one line, you may leave it on one line, omitting the curly braces. In all
other cases, the braces are required. For example, the following are OK:
if (obj->_count == 0) deleteObject(obj);
for (auto block : blocks) block->setParent(nullptr);But these are not acceptable:
if (veryLongVariableName.hasVeryLongFieldName() &&
(rand() % 5) == 0) launchRocket();
if ((err = SSLHashSHA1.update(&hashCtx, &signedParams)) != 0)
goto fail;Avoid assignments in conditional expressions, unless the variable is declared
within the condition, e.g.,
if (auto const unit = getMyUnit(from, these, args)) {
// Do stuff with unit.
}Prefer C++11 foreach syntax to explicit iterators:
for (auto const& thing : thingVec) {
// Do stuff with thing.
}Expressions
- All binary operators should have one space on each side, except for
.,->,.*, and->*which should have zero. - Do not include redundant parentheses unless you think the expression would be
confusing to read otherwise. A good rule of thumb is that if you and/or your
reviewers have to look at a chart of operator precedence to decide if the
expression parses as expected, you probably need some extra parentheses. GCC
or clang may suggest extra parens in certain situations; we compile with-Werrorso you must always follow those guidelines. - If an expression does not fit on one line, attempt to wrap it after an
operator (rather than an identifier or keyword) and indent subsequent lines
with the beginning of the current parenthesis/brace nesting level. For
example, here are some long expressions, formatted appropriately:
if (Cfg::Jit::RegionSelector != "" &&
(Cfg::HHIR::RefcountOpts ||
RuntimeOption::EvalHHITExtraOptPass) &&
Func::numLoadedFuncs() < 600) {
// ...
}
longFunctionName(argumentTheFirst,
argumentTheSecond,
argumentTheThird,
argumentTheFourth);- Function calls should be formatted primarily using the previous rule. If one
or more of the arguments to the function is very wide, it may be necessary to
shift all the arguments down one line and align them one level deeper than
the current scope. This is always acceptable, but is especially common when
passing lambdas:
m_irb->ifThen(
[&](Block* taken) {
gen(CheckType, Type::Int, taken, src);
},
[&] {
doSomeStuff();
lotsOfNonTrivialCode();
// etc...
}
);Constructor initializer lists
If an initializer list can be kept on a single line, it is fine to do so:
MyClass::MyClass(uint64_t idx) : m_idx(idx) {}
MyClass::MyClass(const Func* func) : m_idx(-1) {
// Do stuff.
}Otherwise, it is always correct to format lists thusly:
MyClass::MyClass(const Class* cls, const Func* func, const Class* ctx)
: m_cls(cls)
, m_func(func)
, m_ctx(ctx)
, m_isMyConditionMet(false)
{}
MyClass::MyClass(const Class* cls, const Func* func)
: m_cls(cls)
, m_func(func)
, m_ctx(nullptr)
, m_isMyConditionMet(false)
{
// Do stuff.
}Namespaces
We don't nest namespaces very deeply, so prefer to keep the scoping to a single
line:
namespace HPHP::jit::x64 {
///////////////////////////////////////////////////////////////////////////////
/*
* Some nice documentation.
*/
struct SomeNiceThing {
// some nice properties
};
///////////////////////////////////////////////////////////////////////////////
}Do not increase the indentation level when entering namespace scope. Instead,
consider adding a line of forward slashes as a separator, to more clearly
delineate the namespace (this is especially useful for anonymous namespaces in
source files). This form of delineation is encouraged, but we have no strict
convention for its formatting (you'll see 70- or 79- or 80-character
separators, with or without an extra newline between it and the braces, etc.).
Comments
All public and private APIs in headers should be documented in detail. Names
and notions which are not obvious (e.g., "persistent" or "simple") should be
explained. Preconditions and postconditions should be noted.
Inline code comments are encouraged for complex logic, but their density is
left up to the author. Rather than summarizing/paraphrasing what your code is
doing, focus on explaining what overarching goal the code is achieving and/or
why that goal is necessary or desirable.
Comment style
Here are some comment styles we use or avoid:
// This style of comment is the most common for relatively short inline
// comments. It's fine if it's multi-line.
//
// It's also fine if it has line breaks. The extra newline aids readability in
// this case.
/*
* This style of comment is the right one to use for struct/function
* documentation. Prefer one star on the opening line, as opposed to the
* doxygen standard of two.
*
* This is also sometimes used for inline code comments, although the // style
* makes it easier to comment out blocks of code.
*/
struct ClassLikeThing {
std::vector<const Func*> methods; // This is fine for short annotations.
/* This is also ok, though try not to mix and match too much in a file. */
std::vector<const ClassLikeThing*> parents;
};
/* Don't write multiline comments where some lines are missing their prefix.
This is pretty weird. */Try to use complete sentences in all but the shortest of comments. All comments
should be flowed to 79 characters in width.
Separators
Delineate sections of code with a line of forward slashes. There is no strict
convention, but prefer lines of slashes to other delineators (e.g., /*****/,
five newlines, ASCII-art cartoon characters).
File copyright
All files must begin with a copyright/license notice. For files created by
Facebook employees, the following should be used:
/*
+----------------------------------------------------------------------+
| HipHop for PHP |
+----------------------------------------------------------------------+
| Copyright (c) 2010-201x Facebook, Inc. (http://www.facebook.com) |
+----------------------------------------------------------------------+
| This source file is subject to version 3.01 of the PHP license, |
| that is bundled with this package in the file LICENSE, and is |
| available through the world-wide-web at the following url: |
| http://www.php.net/license/3_01.txt |
| If you did not receive a copy of the PHP license and are unable to |
| obtain it through the world-wide-web, please send a note to |
| [email protected] so we can mail you a copy immediately. |
+----------------------------------------------------------------------+
*/
// File contents start here.We do not require copyright assignment for external contributions, so
non-Facebook contributors should include their own header. The exact contents
are up to you, as long as the license is compatible with the PHP license.
Hphp Doc Ini (`hphp/doc/ini.md`)
Using ini
ini usage in HHVM is fairly similar to that of php-src, albeit currently with
some limitations and enhancements. For example, HHVM currently doesn't support
per-dir ini settings (support coming soon), but it does support vector-based
settings and wildcards for copying and symlinking values from other settings.
Common usage
Here is the typical format of an ini file (values not necessarily realistic):
hhvm.hot_func_count = 11
hhvm.stats.slot_duration = 11
hhvm.env_variables["MYBOOL"] = true
hhvm.env_variables["MYINT"] = 5
hhvm.env_variables["MYSTR"] = "Custom String"
hhvm.env_variables["ANOTHERSTR"] = "Another String"
hhvm.server_variables[] = "Key will be next available int"
hhvm.server_variables[] = "Value will be next available string"
hhvm.error_handling.notice_frequency = 1
hhvm.error_handling.warning_frequency = 1
hhvm.enable_obj_destruct_call = true
hhvm.enable_xhp = trueCopying and Symlinking Settings
NOTE: This feature only currently work with core system settings. They
don't yet work with extensions, ini_set(), etc.
You can also provide wildcards to settings signaling that you want to use the
value of another setting for its value.
@: Copy the value directly into this setting.:: Symlink the value from the other setting to this setting. If the other
setting changes, then this setting will change with it, and vice-versa.
To use this feature, use the form
hhvm.setting[any-sub-key | @ | :][...] = value | "hhvm.substitution-setting"e.g.,
hhvm.a = 3
hhvm.b[@] = "hhvm.a"
hhvm.c[d][@] = "hhvm.a"Here is a more complete example:
hhvm.hot_func_count = 11
hhvm.stats.slot_duration[@] = "hhvm.hot_func_count"
hhvm.server.allowed_exec_cmds[0] = "ls"
hhvm.server.allowed_exec_cmds[1][@]= "hhvm.env_variables[MYSTR]"
hhvm.server.allowed_exec_cmds[2][@]= "hhvm.env_variables[ANOTHERSTR]"
hhvm.env_variables["MYBOOL"] = true
hhvm.env_variables["MYINT"][:] = "hhvm.hot_func_count"
hhvm.env_variables["MYSTR"] = "Custom String"
hhvm.env_variables["ANOTHERSTR"] = "Another String"
hhvm.server_variables[0] = "Key will be next available int"
hhvm.server_variables[1][@] = "hhvm.server.allowed_exec_cmds[0]"
hhvm.error_handling.notice_frequency = 1
hhvm.error_handling.warning_frequency[:] = "hhvm.error_handling.notice_frequency"
hhvm.enable_obj_destruct_call = true
hhvm.enable_xhp[@]= "hhvm.enable_obj_destruct_call"NOTE: If you using this feature with vector or map based settings where you
can specify [] to indicate an in-order increment of a setting, you must
specify explicit indices for them because they will be used to determine which values to be used when copying or symlinking.
Hphp Doc Hackers Guide Bytecode Interpreter (`hphp/doc/hackers-guide/bytecode-interpreter.md`)
The HHVM Bytecode Interpreter
A typical interpreter is implemented using a dispatch loop that looks something
like this:
while (true) {
switch(*pc) {
case ADD:
/* add implementation */
pc++;
break;
case SUB:
/* sub implementation */
pc++;
break;
/*
* handlers for other bytecodes
*/
}
}That is, you have a loop which steps through the bytecode program, examining
each one. The switch statement executes the current bytecode instruction,
advances the program counter, then repeats, until the program terminates.
If you try to find a code structure like this in HHVM, you won’t find it.
Instead, the interpreter is broken up into a number of smaller pieces, some of
which are defined using a series of nested macros. This is to reduce duplication
and to keep it more manageable, although it can make the code seem intimidating
to newcomers.
HHVM's interpreter makes repeated use of the X
Macro pattern to generate code and data
for each bytecode instruction. The list macro is OPCODES, defined in
runtime/vm/hhbc.h.
It contains the name, signature, and attributes for every bytecode instruction.
The "X" macro is O(), which you can see is repeatedly invoked by OPCODES.
Bytecode implementations
HHVM's bytecode interpreter lives in
runtime/vm/bytecode.cpp. For every bytecode
instruction Foo, there is a function iopFoo(). This function contains the
interpreter implementation of Foo, and takes arguments representing all of the
immediate arguments to the instruction. For example, since the Add instruction
takes no immediates, void iopAdd() takes no arguments. Int, on the other
hand, takes a 64-bit integer immediate, so its signature is void iopInt(int64_t imm).
These functions are not called directly by the dispatch loop. Instead, the
dispatch loop calls iopWrapFoo(), giving it a pointer to the appropriateiopFoo() function. These wrapper functions are automatically generated from
each bytecode's signature in hhbc.h. Each one decodes the appropriate
immediates, then passes them to the corresponding iopFoo() function. You
should not have to modify the machinery that generates these functions unless
you add a new bytecode immediate type.
InterpOne
In addition to the hand-written iop*() functions and the macro-generatediopWrap*() functions, the OPCODES macro is used
here
to create a set of functions named interpOne*(). These functions are used by
the JIT when a certain instruction would be too complicated to compile to native
machine code: it syncs all live state to memory, calls the appropriateinterpOne*() function, then resumes running the native code after the
problematic bytecode.
Interpreter dispatch loop
The dispatch loop is in dispatchImpl(), defined in
runtime/vm/bytecode.cpp. It’s declared as:
template <bool breakOnCtlFlow> TCA dispatchImpl()Two different versions are instantiated, one for each possible value ofbreakOnCtlFlow. When breakOnCtlFlow is true, the function will return to
the caller after a control flow (i.e. branch) instruction is encountered. IfbreakOnCtlFlow is false, the interpreter will continue executing
instructions until the current VM entry finishes.
There are two versions of the interpreter loop. The Windows version (indicated
with _MSC_VER) implements an ordinary switch-based interpreter loop, while the
Linux version implements a threaded interpreter. In a threaded interpreter, the
handler for each bytecode jumps directly to the handler for the next bytecode
rather than going to a single central switch statement. This eliminates a jump
to a different cache line and improves branch prediction by allowing the
processor’s branch predictor to find associations between the bytecodes. These
different mechanisms are hidden by the DISPATCH_ACTUAL macro.
There are three separate parts to each bytecode handler. One part for dealing
with Hack debugging, one part for tracking code coverage, and a third part which
implements the actual handler. These are defined in the OPCODE_DEBUG_BODY,OPCODE_COVER_BODY, and OPCODE_MAIN_BODY macros, respectively. In the Windows
version, defined
here,
the three macros are put in a case for each opcode, like the example at the
beginning of this document.
In the Linux version, defined
here,
the threaded interpreter uses a dispatch table with computed
goto. Each
instruction gets three labels: one for each OPCODE_*_BODY macro. These labels
are collected into three different dispatch tables, defined
here.optabDirect points to OPCODE_MAIN_BODY, optabCover points toOPCODE_COVER_BODY, and optabDbg points to OPTAB_DEBUG_BODY. The correct
dispatch table is selected at runtime depending on the current code coverage
configuration, and whether or not a debugger is attached.
Performance
In general, we strongly prefer simplicity over performance in the interpreter.
Any performance-sensitive uses of HHVM rely on the JIT compiler, so we've found
it beneficial to keep the interpreter as straightforward as possible, kind of
like a reference implementation to compare the JIT against. This is an
intentional tradeoff that has resulted in an interpreter that is fairly slow.
One aspect of interpreter performance that we have focused on is inlining
decisions. We've found that most compilers choose to not inline various parts of
the interpreter in ways that measurably hurt performance (even when the
functions are marked inline). To counter this, we use the OPTBLD_INLINE
macro, which forces the compiler to inline functions in optimized builds. It does
nothing in debug builds, so we can still debug the interpreter.
Hphp Doc Hackers Guide Data Structures (`hphp/doc/hackers-guide/data-structures.md`)
HHVM Runtime Data Structures
This page contains a brief overview of the most important runtime data
structures in HHVM. It is not meant to be an exhaustive reference for all
details about these data structures, since those details change too often to
reliably document so far away from the code. Rather, reading the descriptions
here should leave you with a good high-level understanding of what each one is
used for and where to find the code implementing it for further investigation.
Hack-visible values
`DataType`, `Value`, and `TypedValue`
DataType, defined in datatype.h, is an enum
representing the type of a Hack value: KindOfNull means null,KindOfBoolean means bool, KindOfInt64 means int, etc. Some user-visible
types are split into multiple DataTypes to keep track of whether or not a
value is reference counted: KindOfPersistentFoo means "KindOfFoo that we
know is not reference counted." The reverse, however, is not true:KindOfPersistentFoo is a subtype of KindOfFoo, so it is valid to useKindOfFoo with a non-reference-counted Foo.
TypedValue, defined in typed-value.h
represents a single Hack value, and appears in many different places in HHVM. It
contains a DataType and a Value, which is a union with one member for every
possible type. Primitive types (null, bool, int, and float) are stored
inline in the TypedValue, while all other types heap-allocate a data object
and store a pointer to it in the TypedValue.
`ArrayData`
ArrayData is used to represent all
array-like types in Hack: array, dict, vec, and keyset, though you'll
never see any raw ArrayData objects created anywhere. Instead, a specific kind
of array is created and tagged using one of the [several current array HeaderKind values]
(https://github.com/facebook/hhvm/blob/HHVM-3.27/hphp/runtime/base/header-kind.h#L46-L49).
We use a custom vtable to dispatch to the appropriate implementation for allArrayData member functions; the current implementation classes arePackedArray,VanillaDict, andSetArray. Note that many of these types
don't directly inherit from ArrayData, so they're only subtypes of ArrayData
by convention.
Arrays in Hack have value semantics, implemented using copy-on-write. As a
result, most member functions that perform mutations take a bool copy
parameter to indicate whether or not the array should be copied before
performing the mutation. It is up to the caller to call cowCheck() before any
mutations to determine if a copy is necessary. Additionally, any mutation could
cause reallocation of a new ArrayData, either to grow or escalate to a
different array kind. To support this, all mutation functions return a newArrayData*; the ArrayData* that was mutated should be considered dead and
you should use the new one in its place.
`StringData`
StringData represents a Hack string
value. Like arrays, strings have value semantics using copy-on-write, so callers
are responsible for calling cowCheck() before mutating, although the copy must
be done manually with StringData::Make() rather than a bool copy parameter
to the mutator function.
Most StringDatas store their data in space allocated immediately after theStringData. This layout is required in USE_PACKEDPTR, so StringData::m_data
is conditionally
defined.
For normal builds, m_data will usually point right after the StringData, but
it may point elsewhere for strings from APC.
`ObjectData`
ObjectData represents a Hack object. It
contains a Class* describing its type (described below) and a series of
attributes. Declared properties are stored in an array of TypedValues that is
allocated after the ObjectData.
`Smart pointer wrappers`
All of the types described so far have smart pointer wrappers that are generally
used for high-level non-performance-critical C++ code that needs to work with
one of them. The wrapper type can be found by dropping the Data suffix, soStringData's wrapper is String. Note
that like most smart pointer types, these wrappers can all represent a null
value, but the pointer in a TypedValue representing an array, string, etc.
must never be nullptr. A null value is represented using KindOfNull.
Runtime-internal data structures
`Unit`
A Unit represents all of the information contained
in one Hack file: classes, functions, constants, top-level code, etc. All
references to entities that could be defined in another file are only referenced
by name, even if they are defined in the current file. This includes, but it not
limited to, function names in call expressions, parent class names, and used
traits. This is to support one of HHBC's core principles: it is always possible
to emit bytecode (and construct a Unit) for a single file in complete
isolation. If that file references entities that are undefined at runtime, the
appropriate error will be raised.
`PreClass` and `Class`
Every Unit has a list of classes that are defined in the file it came from.
Each of these classes is stored as a PreClass,
which represents the class definition as it is visible in the source code.
Parent classes, used traits, and any other references to entities other than the
class itself are stored as string names.
When a class is defined at runtime, these references are resolved to concrete
entities, producing a Class. The resolved parent
class is stored as a Class* rather than the string name from the PreClass.
This two-level representation of classes is necessary because the mapping
between name and class can change with every request. All classes are redefined
from scratch in each request1, and different definitions of a
class can be selected by including different files, or by putting the
definitions on different control flow paths. Properly-typed Hack code will
never have multiple definitions of the same class, but HHVM still supports it.
`Func`
All Hack functions, including methods on classes, are represented using aFunc. Each Func contains a pointer to the Unit
it was defined in, the Class it is a member of (if any), its location in theUnit's bytecode, information about its parameters, and various other metadata.
Every Func has a 32-bit FuncId, which is assigned in ascending order as eachFunc is created. There is a global table that allows mapping from this ID back
to a Func*, and FuncIds are often used in places where we want to store a
reference to a Func without a full 64-bit pointer2.FuncIds are also guaranteed to be unique for the lifetime of the process,
unlike Func*.
1: This is what HHVM presents to the user, but we have
mechanisms to avoid the work of actually redefining everything in every request
when possible.
2: HHVM also has a USE_PACKEDPTR build mode that allocates
certain data structures, including Funcs, in the lower 32GiB of address space,
allowing us to store 32-bit pointers using HPHP::PackedPtr<T>. However,PackedPtr<T> is 64 bits in non-USE_PACKEDPTR builds, and some uses ofFuncId rely on it being 32 bits in all build modes.
Hphp Doc Hackers Guide Directory Structure (`hphp/doc/hackers-guide/directory-structure.md`)
Directory Structure
There are over 1000 C++ files in HHVM's codebase, split into a number of
different directories. This is a rough overview of what lives where (all paths are under hphp/):
compiler/: The old parser and bytecode emitter. This is deprecated and is currently being removed; the new replacement is in hack/src/hhbc.
doc/: Documentation, of varying age and quality.
hack/src/: The Hack parser, typechecker, bytecode emitter, and various other Hack-related tools.
hhbbc/: HipHop Bytecode-to-Bytecode Compiler, our ahead-of-time static analysis program.
hhvm/: main() for the hhvm binary
neo/: Code to read .hdf files, used for configuration.
parser/: The parser grammar and interface.
pch/: Pre-compiled headers for MSVC (not currently supported).
ppc64-asm/: Code to read and write ppc64 machine code.
runtime/: HHVM's runtime.runtime/base/: Runtime internals related to Hack types and values (strings, objects, etc...).runtime/debugger/: Support for debugging Hack programs at runtime.runtime/ext/: Extensions.runtime/server/: Built-in servers, both for web requests and other HHVM-specific types of requests.runtime/test/: C++ unit tests for various runtime components.runtime/vm/: Most of the internals of the VM. The distinction between runtime/base/ and runtime/vm is a bit fuzzy.runtime/vm/jit/: HHVM's JIT compiler.runtime/vm/verifier/: The bytecode verifier.
system/: systemlib, a collection of Hack code that is embedded into hhvm and always available.
test/: Integration tests for the whole compiler pipeline, written in PHP and Hack.
tools/: Utility programs useful in the development and/or use of HHVM.tools/benchmarks/: Microbenchmarks. If you run these, take them with a pound of salt; we care much more about macrobenchmarks based on real-life workloads.tools/gdb/: Python scripts to assist in debugging hhvm in gdb.tools/hfsort: HFSort, the algorithm used to lay out functions in both hhvm and JIT-compiled code.tools/tc-print/: TCPrint, a tool to analyze the output of the JIT compiler.
util/: Functions and data structures used by HHVM that could be useful to other programs, and have no dependencies on other parts of HHVM.
vixl/: Code to read and write AArch64 machine code.
zend/: Code imported from the standard PHP interpreter.
Hphp Doc Hackers Guide Faq (`hphp/doc/hackers-guide/faq.md`)
FAQ
This document contains answers to some of the most common questions we get from
people as they learn HHVM's codebase. Some of the answers may be pointers to
other pieces of documentation, if that information has a natural home somewhere
else.
How is HHVM configured?
HHVM has a large number of configuration options that can be set with
command-line arguments of the form -vName=Value. Unfortunately, documentation
on them is very sparse, but some can be found in
doc/options.compiled and
doc/options.compiler. The authoritative source for core
runtime options is the RuntimeOption class, in
runtime/base/runtime-option.h and
runtime/base/runtime-option.cpp.
Multiple options may be collected in a .hdf file, described here,
and passed to HHVM with -c path/to/config.hdf. Options given with -v will
override any found in the .hdf. HHVM also has support for PHP's .ini files,
documented here.
How do I see the bytecode for my Hack/PHP script?
Use the Eval.DumpBytecode runtime option:
$ hhvm -vEval.DumpBytecode=1 hello.phpThis will give you output that looks something like
this.
bytecode.specification contains descriptions of the
behavior of every bytecode instruction. Note that running with-vEval.DumpBytecode=1 will still execute the script after printing its
bytecode. To prevent this, add the --lint option, which will exit after
parsing and printing the bytecode.
You can also use Eval.DumpHhas:
$ hhvm -vEval.DumpHhas=1 hello.phpThis gives output similar to
this.
Running with -vEval.DumpHhas=1 will not execute the script after printing
its bytecode.
Why are there two ways to dump bytecode with slightly different semantics? It's
partly for historical reasons that may be cleaned up at some point, but there
are a few meaningful differences between the two:
DumpBytecodeshows bytecode offsets, whileDumpHhasdoesn't. This is because HHAS is designed to be human-writable, so it uses labels rather than raw offsets for control flow. However, the offsets are useful if you're working in the interpreter or JIT, as they use bytecode offsets to locate code.DumpBytecodewrites to the currentTRACEfile (see the next section for details), whileDumpHhaswrites to stdout.- Metadata is printed explicitly, separately from function bodies with
DumpBytecode, whileDumpHhaseither has the same metadata represented inline in the bytecode, or left out when it can be inferred from the bytecode itself.
In general, if you're working with HackC (the parser and bytecode emitter), you
should use DumpHhas, because that's how HHVM and HackC communicate. If you're
working with HHVM's interpreter or JIT, you should use DumpBytecode, since its
more verbose format can be helpful during debugging.
How do I enable debug logging from different parts of HHVM?
The primary method of printing debug information is our tracing system, defined
in util/trace.h. Most of the functionality is disabled in
release builds for performance reasons, so you should be using a debug build if
you want to enable tracing.
Basic usage
The TRACE_MODULES macro in trace.h defines a series of trace modules, each
with a short name. To enable one or more modules, set the TRACE environment
variable to a comma-separated list of module:level pairs. All levels default
to 0, and higher levels are typically more verbose (this is only by convention,
and is not enforced anywhere). For example, to set the hhir module to level 1
and the printir module to level 2, run:
$ TRACE=hhir:1,printir:2 hhvm my_script.phpIf TRACE is set, even to an empty value, tracing is written to/tmp/hphp.log; this can be overridden by setting HPHP_TRACE_FILE to the file
of your choice. If TRACE is not set, all tracing goes to stderr (this applies
to features like Eval.DumpBytecode, described above). To send tracing to
stderr even when TRACE is set, use HPHP_TRACE_FILE=/dev/stderr.
To find out which trace module applies to a specific file, look for a use of theTRACE_SET_MOD macro, usually near the top of the file. To add your own tracing
lines, use either TRACE(level, "printf-style format", args...); orFTRACE(level, "folly::format-style format", args...);. The level argument is
the lowest level at which the trace will be active.
`printir`
If you're working in the JIT, printir is probably the most important module to
be familiar with. It controls the printing of HHIR units as they're constructed
and optimized, and TRACE=printir:1 is the most convenient way to see the
output of the JIT. It's also one of the only modules to have well-defined
meanings for each trace level, defined
here.
It is enabled in all build modes, so you don't have to create a debug build to
use it.
My change is crashing HHVM. What do I do?
HHVM can be debugged with the standard debugging tools for your compiler and
platform. For most of us, that means using
GDB:
$ gdb --args ./hhvm path/to/script.phpThere are a variety of support scripts in tools/gdb to help
with inspecting many of HHVM's data structures; see the README in that directory
for more information on usage.
Common Pitfalls
This section contains answers to questions that most people don't think to ask,
usually because they involve some non-obvious or surprising behavior in HHVM.
`jit::Type`
If you spend any time in the JIT, you'll probably deal with thejit::Type struct. HHIR
is statically typed, so every value is tagged with a Type and every
instruction has a set of acceptable Types for its inputs. If you're used to
types in languages like Java or C++, Type is much more complicated than
something like int or Foo*. We recommend reading about it
here before writing any nontrivial HHIR code. You
don't have to memorize every single Type, but you should make sure you
understand the usage guidelines.
Hphp Doc Hackers Guide Glossary (`hphp/doc/hackers-guide/glossary.md`)
Glossary
This document contains a list of terms, abbreviations, and acronyms that you may
come across either in this guide or in the HHVM source code. Don't worry if none
of the definitions mean anything to you yet; it may make more sense to revisit
this page as needed once you've read some more of the guide.
HHVM/Hack/PHP-specific
The terms in this section are specific to HHVM, Hack, or PHP. Concepts that
apply more generally are listed in the next
section.
APC
The Alternative PHP Cache was
originally added to PHP as a cache for compiled bytecode, to reduce the overhead
of loading a program's source code with every request. It has since been
replaced, but HHVM still supports it as a process-global hash table, used to
share data between active requests and/or cache data to be reused by later
requests.
BOLT
BOLT is a binary optimizer,
developed by Facebook to optimize the performance of large applications.
FPI/FPI Region
Function parameter info and corresponding regions in the bytecode. Described in
detail in bytecode.specification.
HackC
The Hack Compiler, HHVM's new frontend (as of mid-2018). A parser and bytecode
emitter written completely in OCaml.
HHAS
HipHop assembly: a human-readable and -writable representation of HHBC, HHVM's
bytecode.
HHBC
HipHop bytecode, HHVM's custom bytecode. Described in
bytecode.specification.
HHBBC
HopHop bytecode-to-bytecode compiler. Our static analysis engine that performs a
number of offline optimizations on bytecode.
HHIR
HipHop intermediate representation, the primary IR in HHVM's JIT. Described in
ir.specification.
HPHPc
HipHop for PHP, a transpiler
from PHP to C++ and HHVM's predecessor. A large part of HHVM's runtime is
inherited from HPHPc.
HNI
HHVM native interface.
HHVM's mechanism for calling native code from PHP (name inspired by JNI).
RDS/Target Cache
The request data segment, defined in
runtime/base/rds.h is a form of thread-local
storage, used to hold
information that can vary between PHP requests, like the definitions of classes
and functions. The target cache is a collection of request-local caches used
by the JIT that live in RDS.
Repo/Repo authoritative
HHVM stores bytecode in a cache we call the repo, short for
"repository". Repo authoritative is an execution mode in which HHVM assumes that
no code exists outside of what's already in the currently-loaded repo, allowing
much more aggressive optimizations.
TC/Translation cache
The block of memory where HHVM stores JIT-compiled code.
TCA
Translation cache address: a pointer to code or data in the translation cache.
tc-print
Translation cache printer: a tool for inspecting the code generated by HHVM,
optionally annotated with performance events.
Tracelet
A region of bytecode used as the basic unit of compilation in some modes of
HHVM's JIT. No larger than a basic block.
vasm
Virtual assembly, our low-level intermediate representation. Positioned between
HHIR and machine code.
XLS
Extended linear scan, the algorithm used by our register
allocator.
General
If you're new to compilers, performance-sensitive applications, or just curious,
you may want to skim through the following terms:
- ARM: A family of CPU architectures. In the context of HHVM: AArch64, the 64-bit version of ARM.
- AST/abstract syntax tree
- Bytecode
- Basic block
- CFG: control flow graph or context-free grammar (nearly always the former here).
- Compilation unit/translation unit
- COW/copy-on-write
- CPU cache. Also d-cache, data cache, i-cache, instruction cache.
- IR/intermediate representation.
- FP/frame pointer.
- ICF/identical code folding: a compiler/linker optimization that detects and de-duplicates identical blocks of code.
- TLB/iTLB/dTLB
- Bytecode interpreter
- JIT/just-in-time compilation
- Leaf function: a function that calls no other functions; a leaf node in the control flow graph.
- NUMA/non-uniform memory access
- PGO/profile-guided optimization
- RSS/resident set size
- SP/stack pointer
- x86: A family of CPU architectures. In the context of HHVM: x86-64, the 64-bit version of x64.
Hphp Doc Hackers Guide Jit Core (`hphp/doc/hackers-guide/jit-core.md`)
HHVM JIT
HHVM's just-in-time compiler module is responsible for translating sequences of
HipHop Bytecode (HHBC) into equivalent sequences
of machine code. The code implementing the JIT lives in the namespaceHPHP::jit, in hphp/runtime/vm/jit. Most file and
class names referenced in this document will be relative to that namespace and
path.
The basic assumption guiding HHVM's JIT is that while PHP is a dynamically
typed language, the types flowing through PHP programs aren't very dynamic in
practice. We observe the types present at runtime and generate machine code
specialized to operate on these types, inserting runtime typechecks where
appropriate to verify assumptions made at compile time.
Region Selection
The first step in translating HHBC into machine code is deciding exactly which
code to translate, in a process called region selection. The size and shape of
regions depend on many factors, ranging from a single HHBC instruction up to an
entire function with complex control flow. There are a number of different ways
to select a region, all producing a RegionDesc struct in the end (defined in
region-selection.h). RegionDesc
contains a list of RegionDesc::Block structs, and each Block represents a
basic block of bytecodes by holding a starting offset and length in
instructions. The list of Blocks is kept sorted in reverse post order. Blocks
also contain optional metadata about the code they contain and the state of the
VM before, during, and after execution of that code. This metadata includes
type predictions, statically known call destinations, and certain
postconditions.
Tracelet Region Selection
The first-gear region selector is the tracelet region selector. The name
"tracelet" is somewhat vestigal, and refers to a region of HHBC typically no
larger than a single basic block. This code lives in
region-tracelet.cpp, and its goal is
to select a region given only the current live VM state - most importantly the
current program counter and the types of all locals and eval stack slots. Thetranslator module (see the "HHIR" section) is used to simulate execution of
the bytecode using types instead of values, continuing until a control flow
instruction is encountered or an instruction attempts to consume a value of an
unknown type. These rules are not absolute; there are a number of exceptions. If
an unconditional forward Jmp is encountered, the tracelet will continue at theJmp's destination. In addition, certain bytecodes (such as PopC andIsTypeC) are able to operate on generic values and don't cause a tracelet to
terminate due to imprecise types.
PGO
HHVM's JIT has support for profile-guided optimizations, or PGO. The code
supporting PGO is spread throughout many parts of the JIT. This section is a
high-level overview of the major pieces.
Profiling Regions
The tracelet selector has a special mode for selecting "profiling regions".
There are a few differences in profiling mode, indicated by code that inspectsenv.profiling in
region-tracelet.cpp:
- Only the very first instruction in a region is allowed to consume a
referenced value. - Certain bytecodes that normally wouldn't break a tracelet do. See the
instrBreaksProfileBB()function for the list. - All control flow instructions always end the region, including unconditional
Jmps.
The purpose of these restrictions is to create much simpler regions that are
guaranteed to be a single Block, with no mid-region side exits due to
referenced values. These smaller regions also contain execution counters to
track the relative hotness of each basic block of bytecode.
Hotcfg Region Selection
After translating and executing profiling regions for a while, optimized
retranslation is triggered (the exact details of how and where this happens are
currently changing somewhat rapidly, so this sentence is intentionally vague
for now). Once retranslation is triggered, a number of "hot CFGs" are selected
for each function, by the code in
region-hot-cfg.cpp. A hot
CFG is a RegionDesc
containing a non-strict subset of the profiling regions from a function,
stitched together into one large region. It may be a single straight-line path
through the hottest part of a function, or it may be the entire body of the
function, containing loops and other control flow. The exact shape of each hot
CFG depends on which parts of the function were executed during the profiling
phase.
Region Translation
Regardless of the source of the RegionDesc, the translation process from this
point forward is the same. The RegionDesc containing blocks of HHBC is lowered
into HHIR, which is then lowered to vasm, and finally the vasm is lowered to
x86-64, arm64, or ppc64 machine code, depending on the architecture of the host
CPU. Each level of this pipeline has a number of associated optimization passes.
This section describes each lowering process and some of the optimizations.
HHIR
Once a bytecode region has been selected, it is lowered into HipHop
Intermediate Representation, commonly referred to as
HHIR. HHIR is an
SSA-form,
strongly-typed intermediate representation positioned between HHBC and machine
code. A few different classes and modules are involved in this process:
irgen: Declared in irgen.h, this module is
used to convert the bytecode instructions from a RegionDesc into a sequence
of HHIR instructions. OneemitFoo()function is defined for every HHBC
instruction. The implementations for these functions are grouped intoirgen-*.cppfiles (e.g.
irgen-basic.cpp,
irgen-arith.cpp).IRGS: Defined in irgen-state.h, this
class contains all the state tracked during the irgen process. The two most
important pieces areIRBuilderandIRUnit:IRBuilder: Defined in ir-builder.h,
this class tracks state during symbolic execution and performs some very
basic optimizations based on this state.IRUnit: Defined in ir-unit.h, this class
is responsible for creating and storing the runtime data structures that
represent HHIR instructions, values, and blocks.simplify: Declared in simplify.h, this
module is responsible for performing state-free optimizations such as
constant folding and propagation or anything else that only requires
inspecting an instruction and its sources.
Type System
All values in HHIR have a type, represented by the Type class in
type.h. A Type may represent a primitive type
or any arbitrary union of primitive types. Primitive types exist for
Hack-visible types such as Int, Obj, and Bool, and runtime-internal types
such as FramePtr, Func, and Cls. Primitive types also exist for PHP
references and pointers to PHP/Hack values: for each primitive PHP/Hack typeT, BoxedT, PtrToT, and PtrToBoxedT also exist. A number of types
commonly thought of as primitives are actually unions: Str is defined as{PersistentStr+CountedStr} and Arr is defined as{PersistentArr+CountedArr}. Predefined constexpr Type objects are provided
for primitive types and many common union types: simply prepend T to the name
of the type (so TInt represents the Int type, TCell represents the Cell
type, etc...).
In addition to arbitrary unions of primitive types, Type can also represent
constant values and "specialized" types. A constant Type may represent the
integer 5 (created with Type::cns(5)) or the string "Hello, world!"
(Type::cns(makeStaticString("Hello, World!"))), while a specialized type can
represent an object of a specific class (Type::ExactObj(cls)) or an array of a
specific kind (Type::Array(ArrayData::kSomeKind)).
Since types represent sets of values, we define relations on types in terms of
the sets of values they represent. Two types S and T are equal (S == T)
iff they represent equal sets of values. S is a subtype of T (S <= T) if
the set of values represented by S is a subset of the set of values
represented by T. S and T are not related if their intersection is the
empty set (also called Bottom).
As previously mentioned, types in HHIR represent a mix of Hack-visible types and
internal types. The following table describes types representing Hack values.
Note that the types used here are more specific than what can be discriminated
by user code (e.g., StaticStr and CountedStr both appear as type "string" at
the Hack level).
| Type | HHVM representation |
|---|---|
| Uninit | KindOfUninit |
| InitNull | KindOfNull |
| Null | {Uninit+InitNull} |
| Bool | false=0, true=1 (8 bits at runtime) |
| Int | int64_t (64-bit two's complement binary integer) |
| Dbl | double (IEEE 754 64-bit binary floating point) |
| StaticStr | StringData* where isStatic() == true |
| SharedStr | StringData* where isShared() == true |
| PersistentStr | StringData* {StaticStr+SharedStr} |
| CountedStr | StringData* where isRefCounted() == true |
| Str | StringData* {PersistentStr+CountedStr} |
| *Arr | ArrayData* (same variants as Str) |
| *Vec | ArrayData* where kind() == Vec |
| *Dict | ArrayData* where kind() == Dict |
| *Keyset | ArrayData* where kind() == Keyset |
| UncountedInit | TypedValue: {Null+Bool+Int+Dbl+PersistentStr+PersistentArr} |
| Uncounted | TypedValue: {UncountedInit+Uninit} |
| Obj | ObjectData* |
| Obj<=Class | ObjectData* of the specified Class or one of its subclasses |
| Obj=Class | ObjectData* of the specified Class (not a subtype) |
| Cls | Class* |
| Func | Func* |
| Counted | {CountedStr+CountedArr+Obj+BoxedCell} |
| Cell | {Null+Bool+Int+Dbl+Str+Arr+Obj} |
The VM also manipulates values of various internal types, which are never
visible at the PHP level.
| Type | HHVM representation |
|---|---|
| PtrToT | Exists for all T in Cell. Represents a TypedValue* |
| Bottom | No value, {}. Subtype of every other type |
| Top | Supertype of every other type |
| VarEnv | VarEnv* |
| NamedEntity | NamedEntity* |
| Cctx | A Class* with the lowest bit set (as stored in ActRec::m_cls) |
| Ctx | {Obj+Cctx} |
| RetAddr | Return address |
| StkPtr | Pointer into VM execution stack |
| FramePtr | Pointer to a frame on the VM execution stack |
| TCA | Machine code address |
| Nullptr | C++ nullptr |
Usage guidelines
We've observed some common misuses of Type from people new to the codebase.
They are described here, along with how to avoid them.
Comparison operators
Since a Type represents a set of values, the standard comparison operators onType perform the corresponding set operations:
==,!=: Equality/inequality<,>: Strict subset/strict superset<=,>=: Non-strict subset/non-strict superset
One important consequence of this is that a strict weak
ordering does not exist
for Type objects, which means Type cannot be used with algorithms likestd::sort(). Put another way, many pairs of Types cannot be ordered: bothTInt < TStr and TInt >= TStr are false, for example. As long as you think
in terms of set comparisons and not numerical ordering, it should be fairly
intuitive.
To check if a value is of a certain type, you almost always want to use <=.
So, instead of val->type() == TInt, use val->type() <= TInt, or more
compactly, val->isA(TInt). Using exact equality in this situation would give
unexpected results if val had a constant type, like Int<5>, or if we ever
added other subtypes of Int (with value range information, for example).
A related problem is determining when a value is not of a certain type. Here,
the difference between "val is not known to be an Int" and "val is known
to not be an Int" is crucial. The former is expressed with !(val->type() <= TInt), while the latter is !val->type().maybe(TInt). Types like {Str+Int}
illustrate the difference between these two predicates: ((TStr | TInt) <= Int) == false and (TStr | TInt).maybe(TInt) == true.
Inner types
HHIR programs are in SSA form, so a value can't change once it has been defined.
This implies that the value's type also can't change, which requires extra
consideration when working with types that have inner types, like PtrToFoo.
The immutability of types also applies to these inner types, so when reading
from or writing to a PtrToInt, it is safe to assume the the pointee is always
an Int.
This may sound obvious, but there are some situations in which you may find
yourself wanting to construct a value with an inner type that can't be relied
on. Flow-sensitive type information should not be used to feed inner types,
especially runtime type guards. Much of HHBBC's type inference is
flow-insensitive, and that can safely feed inner types of pointers.
There is one small tweak to the rule "a value's type cannot change once
defined": if the pointee is destroyed, the pointer can't be safely dereferenced,
and the type doesn't have to be valid anymore. One example of this is an object
property that HHBBC says is always a Dbl. It's safe to create a PtrToDbl
pointer to the property, because the information from HHBBC is flow-insensitive.
But if the object is freed and a new object is allocated at the same memory
address, it's possible that this pointer will now point to something completely
different. This is fine, because dereferencing the pointer would be analogous a
use-after-free bug in C++, resulting in undefined behavior.
Values, Instructions, and Blocks
An HHIR program is made up of Blocks, each containing one or moreIRInstructions, each of which produces and consumes zero or more SSATmp
values. These structures are defined in
block.h,
ir-instruction.h, and
ssa-tmp.h, respectively.
An SSATmp has a type, an SSA id, and a pointer to the IRInstruction that
defined it. Every SSATmp is defined by exactly one IRInstruction, though oneIRInstruction may define more than one SSATmp. Every instruction has anOpcode, indicating the operation it represents, and a BCMarker, which
contains information about the HHBC instruction it is part of. Depending on the
opcode of the instruction, it may also have one or more SSATmp* sources, one
or more SSATmp* dests, one or more Edge*s to other Blocks, a Type
parameter (known as a typeParam), and an IRExtraData struct to hold
compile-time constants. IRInstructions are typically created with theirgen::gen() function, which takes an Opcode, BCMarker, and a variadic
number of arguments representing the other properties of the instruction.
A Block represents a basic block in a control flow graph. A pointer to oneBlock is stored in IRUnit as the entry point to the program; all otherBlocks must be reached by traversing the CFG. Certain instructions are "block
end" instructions, meaning they must be the last instruction in their Block
and they contain one or more Edges to other Blocks. Jmp is the simplest
block end instruction; it represents an unconditional jump to a destination
block. CheckType is an example of an instruction with two Edges: "taken" and
"next". It compares the runtime type of its source value to its typeParam,
jumping to "taken" block if the type doesn't match, and jumping to the "next"
block if it does.
While block end instructions may only exist at the end of a Block, there are
two instructions that may only exist at the beginning of a Block: DefLabel
and BeginCatch. DefLabel serves as a phi-node, joining values at control
flow merge points. BeginCatch marks the beginning of a "catch block", which
will be covered in more detail later.
Control Flow
HHIR can represent arbitrary control flow graphs. In place of traditional
phi-nodes, HHIR uses Jmp instructions that take sources and pass them toDefLabel instructions. Consider the following program that performs some
numerical computation:
B1:
t1:Int = ...
JmpZero t1:Int -> B3
-> B2
B2:
t2:Int = AddInt t1:Int 5
Jmp t2:Int -> B4
B3:
t3:Dbl = ConvIntToDbl t1:Int
t4:Dbl = MulDbl t3:Dbl 3.14
Jmp t4:Dbl -> B4
B4:
t5:{Int|Dbl} = DefLabel
...After control flow splits at the end of B1, B2 and B3 each do their own
computation and then pass the result to the DefLabel at the join point,
B4. This is equivalent to the following phi-node: t5:{Int|Dbl} = phi(B2 -> t2:Int, B3 -> t4:Dbl)
Optimizations
Two types of basic optimizations are performed on each instruction byIRBuilder as it is generated and added to its Block: pre-optimization and
simplification. Pre-optimization performs simple optimizations based on tracked
state in IRBuilder, such as replacing a LdLoc instruction with a use of an
already known value for that local variable. Simplification performs any
optimizations that only require inspecting an instruction and its sources. This
is primarily constant folding but also includes things like eliminatingCheckType instructions that are known at compile-time to succeed. Both
pre-optimization and simplification may return an already-defined value to use
instead of the new instruction, or they may generate one or more different
instructions to use in place of the original.
Once the initial translation pass is complete, a series of more involved
optimizations are run on the entire CFG. These are described in detail in
jit-optimizations.md.
Machine Code Generation
Register Allocation
Code Generation
Hphp Doc Hackers Guide Jit Optimizations (`hphp/doc/hackers-guide/jit-optimizations.md`)
HHVM JIT Optimization Passes
Guard Relaxation
By default, every live program location read in a translation creates a type
guard. There are currently 10 different primitive types that can be guarded on,
so a translation with just 4 guards can have up to 104 unique
combinations of input types, leading to a combinatorial explosion in the number
of retranslations required to support all runtime type combinations. This is
bad news for both JIT code size and runtime performance - these retranslations
chain linearly to each other, so the time it takes to make it past the guards
is O(nTranslations) in the worst case. The runtime optionEval.JitMaxTranslations limits the number of translations allowed perSrcKey, and once this limit is hit any further retranslation requests will
result in a call out to the interpreter. This is almost always less desirable
than generating slightly suboptimal machine code in one or more of the
translations, allowing it to accept a variety of input types. The process we
use to determine which type guards can be loosened is called guard relaxation.
There are two parts to guard relaxation: value constraining and the relaxation
itself. Value constraining happens during the initial IR generation pass, and
is managed by the IRBuilder class. The
important methods in this process are IRBuilder::constrainValue(),IRBuilder::constrainLocal(), and IRBuilder::constrainStack(). Whenever
the behavior of a sequence of HHIR depends on the types of one or more values,
the types of those values must be constrained using the constrain* methods
previously mentioned. Each takes the value to be constrained and how it should
be constrained. The possible constraints are defined in the DataTypeCategory
enum in datatype.h and are defined in order of
ascending specificity:
DataTypeGenericindicates that the type of the value does not matter and is
equivalent to not constraining the value at all. This is most often used for
values that are simply moved from place to place.DataTypeCountnessindicates that the only use of the value is to incref or
decref it. If the value's type is not refcounted, the type guard may be
relaxed toUncounted.DataTypeCountnessInitis similar toDataTypeCountness, with the exception
that guards for the typeUninitwill not be relaxed. This is most commonly
used for bytecodes likeCGetL, whereUninitvalues cause a notice to be
raised while all other uncounted types are treated equally.DataTypeSpecificis the default constraint, and indicates that the guard
should not be relaxed past a specificDataType.DataTypeSpecializedindicates that in addition to requiring the value'sDataType, there is an additional type tag in the value that must be
checked. Currently this includes object classes and array kinds, used by
MInstrTranslator to emit more
efficient machine code for some container objects.
The guard relaxation process often needs to track additional information beyond
a single DataTypeCategory. The TypeConstraint struct, defined in
type.h is used to hold this information. A
value's type constraint is typically specified by passing a TypeConstraint
(DataTypeCategory implicitly converts to TypeConstraint) to value accessor
methods like HhbcTranslator::popC() and HhbcTranslator::ldLoc(). The former
is used extensively and so its TypeConstraint parameter is optional,
defaulting to DataTypeSpecific.
TypeConstraint, when used with DataTypeSpecialized, also requires
additional information about what property of the type is desired. This
information can be specified using the setWantArrayKind() andsetDesiredClass() methods.
Note that any decisions made in the JIT based on a value's type must be
reflected in that value's type constraint. This includes situations where the
absence of code depends on a value's type, such as eliding refcounting
operations on non-refcounted values. Typically, this just means usingDataTypeSpecific and giving no further thought to guard relaxation. If,
however, the operation you are translating can tolerate less specific types,
use an appropriate type constraint and ensure that any HHIR opcodes emitted can
tolerate having the types of their inputs loosened.
When IRBuilder is instructed to constrain the type of a value, it walks up
the chain of instructions leading to the value's definition, looking for the
instruction that determined the value's type. Sometimes this instruction is an
opcode with a known output type, such as ConcatStrStr which always produces aStr. In these cases nothing is constrained, since the value's type does not
depend on a guard. When the source of a value's type is found to be a guard
instruction such as GuardLoc, GuardStk, or CheckType, the current
constraint for that guard is tightened according to the new constraint. All
guards start as DataTypeGeneric and may only have their constraints
tightened. The constraints for all guards are stored in a GuardConstraints
object owned by IRBuilder.
Certain optimizations performed by Simplifier are disabled during the initial
IR generation pass. The best example of this pattern is the IncRef
opcode. When given an input value of an uncounted type, the IncRef can be
eliminated. However, if the input type of the instruction may be loosened by
guard relaxation, it must not be eliminated. The reoptimize pass, described
below, eliminates instructions that are still eligible for simplification after
guard relaxation runs.
Once initial IR generation is complete, guard relaxation is the first
optimization pass to run. This is to simplify the other passes: any
modifications of the IR performed before guard relaxation would have to be
careful to keep the GuardConstraints map updated, and the loosened types
produced by guard relaxation may affect the behavior of other passes. The
relaxation pass is implemented in relaxGuards() in
guard-relaxation.cpp. It is a
fairly simple pass: for all guard instructions present in the trace, their
constraint is looked up in the GuardConstraints. If the type of the guard is
more specific than is required by its constraint, the type is loosened. Some
guards will be loosened all the way to Gen; these guards will be eliminated
in the reoptimize pass. After loosening guard types as needed, one more pass
over the trace is performed to recompute any types derived from the modified
guards.
Most code in the JIT doesn't have to care about guard relaxation. If you're
implementing an operation that is type agnostic (or can be made type agnostic
without bloating the code much), it may be a good candidate for something more
general than DataTypeSpecific. Be aware that guard relaxation's effect on the
generated code is almost always negative, so it should only be used in
situations where the benefits of having fewer translations for the currentSrcKey outweigh the increased complexity of the generated code.
Dead code elimination
Hphp Doc Hackers Guide Memory Management (`hphp/doc/hackers-guide/memory-management.md`)
HHVM Memory Layout and Management
This page contains a bird's eye view of HHVM memory layout and management. WIP
Stack
C++ stack
The normal function call stack
VM stack
Contains a mix of TypedValues and Activation Records. Vmsp points to this
Both grow down (high address -> low address) and shrink upwards in parallel as the request runs
The OS thread stack is manipulated by compiled C++ as it runs. The VM thread stack is manipulated by the interpreter or compiled JIT code as it runs. When JIT code or the interpreter call into C++, they use the OS stack.
If you're running the interpreter (no jit) then the vm stack grows/shrinks with PHP call but you stay in a single C++ activation frame the whole time.
HHVM Request Heap
All refcounted objects go to heap, all inherit from HeapObject which keeps the refcount. If the refcount goes to 0, the object is freed immediately by pushing it on a freelist (see MemoryManager)
Periodically a mark-sweep garbage collector marks transitive objects starting from roots (RDS, C++ stack, and the VM stack), and frees objects not reachable from a root, even if they have a nonzero refcount. If there were no refcounting bugs, this would only identify objects in or reachable from reference cycles. But with refcounting bugs (missing decrefs), it also cleans up objects whose refcount is incorrect.
The JIT uses profiling to identify decref sites that never reach 0 or 1, and can optimize away the decref. This leads to floating garbage that only the mark-sweep GC can reclaim. It was a small win at some point in the past.
RDS (see below) and the 2 stacks are the root set of the heap.
Two special types of heap objects
Uncounted
Live longer than a request, managed by other mechanisms, note that even uncounted objects have a sentinel refcount but it's used by garbage collector whose value marks the object as uncounted. The refcount primitives use the sentinel to avoid mutating the count of objects shared between requests. The GC uses memory ranges to identify request heap objects without inspecting their refcount
Static
Never freed once created. In lowptr builds these are allocated in the low 4GB so we can use 32-bit pointers - see LowPtr
Together, both of the above go by KindOfPersistent data type.
RDS
Special, fixed sized area for per request data with request lifetime, addressable consistently by any request.
RDS::link stores the index of each item in the process memory, while a request is executing
vmtl (or R12 in jit'd code) points to the request's RDS segment.
RDS consists of a header, a normal segment, a local segment (persistent but non shared) and a persistent segment (shared persistent)
Read more: hphp/runtime/base/rds.h
APC
Key value store that sits outside the process memory. Keys are string, values can be {string,object,array}. Objects are stored in a serialized representation while array etc in their native form. Values in APC can be deleted or expire. Treadmill is used to reclaim memory.
APC is implemented as a tbb::ConcurrentHashMap, containing pointers to uncounted objects that live it's all in the hhvm process heap (but not individual request heaps).
Memory Management
Reference counting
HHVM's user heap is managed primarily with reference counting. All types that
represent a user-visible value inherit fromHeapObject, which contains the value's
reference count, a HeaderKind that describes its type, some mark bits for use
by the garbage collector, and some padding bits available for type-specific
uses. HeapObjects are created with a reference count of 1, and this count is
incremented or decremented as references to it are created or destroyed.
When a HeapObject's reference count goes to 0, it is destroyed.
Garbage collection
HHVM also has a mark-sweep garbage
collector that runs
as a backup for reference counting. HeapObjects will be freed by the garbage
collector if they are not reachable from the request's roots, for example when
the only references to them are part of a reference cycle. The collector
primarily lives in heap-collect.cpp and
heap-scan.h, with scanning
code
spread throughout various other files, situated with the types being scanned.
For information on annotations, see type-scan.h.
Hphp Doc Hands On Lesson0 (`hphp/doc/hands-on/lesson0.md`)
Lesson 0: Building and running HHVM
Lesson goals
- Understand what HHVM is and what it can be used for
- Set up a devserver and build HHVM
- Run HHVM on a small standalone Hack file, and examine its output
Step 0: Starting a build
Kick off the following steps before proceeding through the guide. They take
time, so we want to get them started now.
As performance engineers, we're always looking for ways to optimize our lives =)
- Consult the main documentation for system requirements.
- Check out the HHVM repository.
- Kick off a build of HHVM.
What is HHVM?
HHVM is a compiler and runtime for the dynamically-typed language Hack.
Hack is a variant of PHP. Like PHP, Hack is a good language for writing the
backend of a web application. Large websites are written in Hack, including
facebook.com.
What are our goals for HHVM?
We'd like to improve HHVM in two main ways:
- Better performance: By optimizing the machine code that HHVM generates,
we can realize significant capacity wins for people who are deploying code
and websites built in Hack. - Language improvements: We work with the Hack language team to improve
the language itself. These improvements include changes that make Hack
simpler and safer (e.g. Hack Array Migration)
as well as new language features (e.g. readonly).
Performance wins in HHVM can save our users lots of money. That's why there
are so many $$$ in Hack code!
How does HHVM work?
HHVM has two strategies for executing Hack code:
- An interpreter:
basically, a while loop that executes Hack code step-by-step. - A just-in-time (JIT) compiler:
a compiler that can compile a whole sequence of Hack code into optimized
machine code, at runtime. Most webservers running HHVM use the JIT to compile
code while serving web requests.
Let's say that one more time, because it's a bit strange and magical! HHVM
is a JIT compiler, which means that it can compile Hack code while executing
that code. We'll discuss this idea at length later.
HHVM's interpreter and JIT are interoperable; to execute a given web request,
we might run some JIT-ed code for some Hack functions and use the interpreter
for others. JIT-ed code is typically 10-50x faster than interpreted code. Our
aim is to maximize overall performance, measured as webserver throughput. To
do so, we preferentially JIT the "hottest" (most expensive) Hack functions.
In typical usage, we may only JIT a small fraction (say 1%) of a web codebase,
but that may be enough coverage for us to spend ~99% of our time in JIT-ed code
and only ~1% of time in the interpreter.
Step 1: Running HHVM
Since you've completed step 0 already ;) you should have an HHVM binary sitting
in your build directory. Let's poke it. Here's what mine says if I ask for its
version. Yours will have a different compiler ID, repo schema, etc. The
compiler ID should match the git hash at which you compiled HHVM:
$ hg log -r . -T{node}
e0e849921332a9ccacf5d06a6ad05b87bc0ae0ba
$ ./build/hhvm --version
HipHop VM 4.141.0-dev (dbg) (lowptr)
Compiler: default-0-ge0e849921332a9ccacf5d06a6ad05b87bc0ae0ba
Repo schema: 3eabd3c8d14119f88cbf4ade871a893128d72200Let's compile and run a simple PHP file. Create a directory called ~/php -
that's where all of our code examples will live! - and then put the following
code into ~/php/hello-world.php:
<?hh
<<__NEVER_INLINE>>
function add($a, $b) {
return $a + $b;
}
<<__EntryPoint>>
function main() {
var_dump(add(7, 10));
var_dump(add(7.0, 10.0));
}We're going to use a script called "hhvm_wrapper" to run HHVM on this file.
This script is unnecessary for simply running a file, but it makes it much
easier to run HHVM in various compilation modes that mimic its production
behavior, and to see debugging output. As a result, we will always use
hhvm_wrapper in our examples. Here's a simple invocation:
$ hphp/tools/hhvm_wrapper.php ~/php/hello-world.php
int(17)
float(17)Okay, that's good. HHVM can do simple arithmetic correctly...phew!
What's more interesting about this example is that we called add once with two
integers, and once with two floating-point values. Integers and floats have
completely different representations at the hardware level, and we need to use
different machine code instructions (and even different arithmetic circuitry)
to do math on these two types. And yet, HHVM somehow compiles machine code for
"add" that works for both cases. How does that work?
Well, what we do is...
...
...
...
...
...
...
...
...
...
...
...
...
...
...
...
...
Nope! The answer to that question is way beyond the scope of this lesson.
Step 2: Down the rabbit hole
What we can offer you, for now, is a glimpse into what HHVM is doing behind the
scenes. Let's run hhvm_wrapper in two more modes. First off, let's look at
HHVM's bytecode representation. We'll add the flag--hdf Eval.DumpHhas=1 to
enable bytecode output. This flag disable code executions, so we'll only see
the bytecode, no results:
$ hphp/tools/hhvm_wrapper.php --hdf Eval.DumpHhas=1 ~/php/hello-world.phpThere's no guarantee that you'll get exactly what I got, but if you focus on
the bytecode for the "add" function, you might see something like the following
output:
.function{} ["__NEVER_INLINE"("""v:0:{}""")] (4,6) <"" N > add($a, $b) {
.srcloc 5:15,5:16;
CGetL $b
CGetL2 $a
Add
.srcloc 5:3,5:17;
RetC
}We're doing something with locals $a and $b - in a funny order! - and then
we're executing the "Add" bytecode. That makes sense, right? The most important
takeaway from this small test case is that Hack bytecode is untyped. When we
compile Hack source code to bytecode, we don't know the types of Hack values,
so we don't emit type-specific code. The "Add" bytecode takes two inputs and
does "whatever + does in Hack" for those inputs.
Hack is a dynamic language, and we can write functions - like this "add"
function, or like "Vec\map" - that operate on many different types of inputs.
As a result, Hack bytecode (usually) does not contain type information.
Now, let's take a look at the next steps of compilation: an intermediate
representation (IR) and the resulting machine code. Let's add a few flags to
our original command:
TRACE=printir:1will cause us to print both IR and machine code.-r 2will make HHVM run the code twice, then recompile the code in a more optimized way.| less -RNis just a Unix pager for this output. There's gonna be a lot of output!
$ TRACE=printir:1 hphp/tools/hhvm_wrapper.php -r 2 ~/php/hello-world.php | less -RNDon't be intimidated by the volume of output here! Focus on the colors. Aren't
they pretty?
Okay, let's examine a tiny piece of this code. Search for "TransOptimize" in
this output. (In less, you can use "/" to search. That means you should type
"/TransOptimize", then press Enter. Then, you can type "n" to jump to the next
instance of that string, and "N" to jump back to the previous one.)
We're searching for "TransOptimize" because we're only looking for optimized
code generation; unoptimized output looks very, very different. The first
optimized function you'll find is probably "main". You can tell where you are
because the function is labeled (in purple, for me) near that header - I see,
"Function main" (after the digraph output). Keep jumping to the next instance
of "TransOptimize" until you find one for "add". There should be two compiled
sections for this function - one for integers, and one for floats!
HHVM's JIT automatically specializes functions for the most common input
types to that function.
Can you tell which compilations of "add" correspond to integers, and which to
floats? Hints:
- Look at the "CheckLoc" - that is, "check a local's type" - operations that appear early in each compilation of "add".
- Remember that "Dbl", or "double", is a 64-bit float.
Lesson summary
- HHVM is a just-in-time or JIT compiler for the dynamically-typed programming language, Hack.
- A JIT compiler can compile code at runtime, and can choose which functions to compile and which to interpret.
- You can use hhvm_wrapper to execute a given Hack file, or to look at intermediate steps of compiler output.
Exercises
Let's modify our "hello-world.php" script to investigate Hack bytecode and HHVM's IR.
- If we change "add" to "return $a + 2 * $b", how does its bytecode change?
Which arithmetic operation is done first? - With this modified "add" function, how do we implement the "times 2" bit for
integer $b? Look at the printir! - Suppose, instead, we modify main to include these two additional lines. How
many TransOptimize compilations do we now generate for the "add" function?
Does this number make sense? Why?
var_dump(add(7, 10.0));
var_dump(add(7.0, 10));Now, let's go back to the original version of "hello-world.php", and look at
the printir trace for "main", instead.
- How many function calls do we make in the TransOptimize compilation for "main"?
- If we remove the
<<__NEVER_INLINE>>hint on "add", how does the
TransOptimize compilation for "main" change? How many function calls do we
make there now?
Hphp Doc Hands On Lesson1 (`hphp/doc/hands-on/lesson1.md`)
Lesson 1: Understanding Hack runtime types
Lesson goals
- Learn about the most common user-visible types in Hack.
- See how HHVM uses a "TypedValue" to represent any Hack value.
- Modify the interpreter to treat "+" as the operator for string concatenation!
Step 0: Pull, update, and re-compile HHVM
If you've been following along, you should have set up your environment
in the prior lesson. If not, do so now.
Since it takes quite a while for HHVM to compile, you should generally kick
off a build whenever you plan to edit its code, before making the edits. We're
going to modify the interpreter in this lesson, so start a build now.
Step 1: User-visible Hack types
Hack is a dynamically-typed language. Most Hack values will be one of the
following types:
- null
- bool
- int
- float
- string
- vec
- dict
- keyset
- object
Let's write and execute a small Hack program that uses these types. Save the
following as ~/php/runtime-types.php:
<?hh
class C { function __construct() {} }
function show($x) {
$repr = json_encode($x);
if (!$repr) $repr = '<unserializable>';
print('$x is a '.gettype($x).': '.$repr."\n");
}
<<__EntryPoint>>
function main() {
show(null);
show(true);
show(17);
show(0.5);
show('hello');
show(vec[true, 17]);
show(dict['a' => true, 'b' => 17]);
show(keyset[17, 'hello']);
show(new C());
}As always, we'll run this code with HHVM-wrapper:
hphp/tools/hhvm_wrapper.php ~/php/runtime-types.phpNow, let's try to see if there are any other types. Modify the file to call
"show" on the following additional values:
show(shape('a' => true, 'b' => 17)); // A shape
show($x ==> $x * $x); // A closure
show(show<>); // A function pointerWhich of these values have new runtime types?
At this point, you may be confused. Hack - that is, the "hh" type checker -
treats shapes and dicts as different types, but as the above examples show,
they have the same type at runtime. To explain what's going on, we need to look
at how this runtime type system differs from the Hack type system.
Runtime types vs. the Hack type system
So far, we've been talking about the type of a value at runtime. That's related
to, but NOT the same as, the type of the value in the Hack type system! Here's
why a runtime type and a type system's type annotation are different:
- A type annotation constrains what a value could be, but a runtime type
tells us what the value actually is. - A type annotation can include union types, like Hack's "arraykey" (int | string)
"num" (int | float), or even "mixed" (union of everything!) but at runtime,
any given value must have some specific runtime type.
Let's consider an example. If a parameter $x is typed as an arraykey, then at
runtime, any given value for $x is EITHER an int OR a string. It doesn't make
any sense for it to be both! At the level of the type system, though, we're
just putting constraints on $x, so "arraykey" does make sense as a type
annotation.
To put it yet another way: the "mixed" type annotation means "no constraint".
No value is "mixed" at runtime!
Finally, there's an important fact that's specific to the Hack type system: it
is unsound. That means that there's no guarantee that a given Hack type
constraint for a value will actually match the value's type at runtime. We can
use the Hack type system to identify likely bugs, but we cannot assume that all
of its type annotations are correct. Later on, we'll see that HHVM's JIT
includes an alternate, sound type system, and that this type system is
essential to generating efficient machine code!
A "sound" type system provides some kind of guarantee about runtime behavior.
Typically, the guarantee is that if the whole program type-checks, then when it
is executed, the runtime type of every value will match its type annotation.
One reason that the Hack type system is unsound is that it includes HH_FIXME
and UNSAFE_CAST as "escape hatches" that allow you to ignore type errors. There
are other reasons, too.
For now, we're going to completely ignore Hack's type system. Type annotations
are optional; HHVM can execute Hack code without any type annotations. But as
we've learned, HHVM still tracks the runtime type of the values it operates on!
Step 1: Representing Hack values
At runtime, HHVM needs a way to represent a Hack value that can be any of
Hack's dozen-or-so runtime types.
We use the classic "C-style" solution to this problem: a tagged union.
Let's consider this idea in C-like pseudocode:
enum DataType : uint8_t {
KindOfNull = 0,
KindOfBool = 1,
KindOfInt64 = 2,
...
KindOfObject = 8
};
struct Value {
union {
// Null doesn't need a value
bool m_bool;
int64_t m_int64;
...
ObjectData* m_obj;
};
};
struct TypedValue {
Value m_data;
DataType m_type;
};Basically, this code says that every Hack value is represented as a TypedValue
struct. This struct has two fields: a "tag" byte called DataType, which tells
us what kind of value the TypedValue contains, and the Value, which contains
overlapping storage for each of the different kinds of values. (The "union"
keyword means: "the following fields occupy the same location in memory".)
The first thing we should note about this kind of code is: this kind of C++
data structure is not safe.
If we read or write to m_data using the wrong interpretation of its union
field, we might accidentally read and use an int64_t value as an ObjectData*
pointer. In doing so, we can arbitrarily corrupt the heap memory of our
runtime!
Folks like to say that bugs like this one could result in nasal
demons, but in practice,
the most common outcome is that we produce incorrect results for some Hack
functions, then segfault soon afterwards =)
On the HHVM team, we put up with unsafe practices like the above because it's
the easiest way to express the low-level behavior that we want the machine to
execute. JIT compilation is inherently unsafe, since any bug in the compiled
output is going to produce incorrect behavior that's worse than pretty much any
bug in regular C++ code.
Now, let's look at our actual implementation of this idea. It appears in two files:
Take a look through these files. Do you see the "DataType" enum? What about
"struct TypedValue", in the latter file?
Step 2: Operating on Hack values
Now, we're going to look at the simplest possible way to operate on these Hack
values.
As we saw in the previous lesson, HHVM can execute an "Add" bytecode to do
"whatever Hack + does" for two arbitrary Hack values! Like all operations in
HHVM, this "Add" bytecode is implemented in both the interpreter and the JIT.
In the interpreter, the implementation of "Add" is as simple as it gets:
- We pop two inputs from the stack. (Our bytecode assumes that we're using a "stack machine".)
- We check the input types. Today, "Add" only works on "numeric" - int or float - inputs. We throw on other types.
- We switch over each of the valid input types, and implement the addition logic for each one.
- We push the resulting TypedValue output back onto the stack.
Now, Hack already has a string concatenation operator: ".". But perhaps you may
have wondered: why not use "+" for string concatenation in Hack, like other
languages like JavaScript and Python support? Let's make it happen!
The logic for the interpreter is in the following file:
Search in this file for a function called "iopAdd". This function implements
the logic above. Read it, and see how it works. You should find that it uses a
generic helper to implement the "pop 2 inputs from the stack and push 1 output"
logic. This generic helper takes the binary operation to perform on the two
inputs as a parameter, and for "iopAdd", that operation is "tvAdd", which is in
this file:
Once you've read enough of these functions to have a basic understanding,
modify them so that tvAdd also concatenates two string inputs! Use the
definition of the TypedValue, above, and the API for strings in this file to
help you out:
After making these changes, recompile HHVM. Remember that your implementation
ONLY modifies the behavior of the interpreter - NOT the JIT! That means that
HHVM will have two different behaviors based on whether we are interpreting or
compiling a piece of Hack code. That's not good, but for now, we can still test
this interpretation by running HHVM with the JIT disabled. Put the following
test case into ~/php/concat.php:
<?hh
function add($x, $y) {
return $x + $y;
}
<<__EntryPoint>>
function main() {
var_dump(add(17, 34));
var_dump(add(17.0, 34.0));
var_dump(add("17", "34"));
}Then run:
hphp/tools/hhvm_wrapper.php -i ~/php/concat.phpIf you've done this step, you should see the results 51, 51.0, and "1734"
printed to stdout!
Lesson summary
- Hack is a dynamically-typed language. At runtime, a Hack value has some
specific runtime type. - A "type system" constrains what types a value may have. A type system can be
"sound", meaning that its claims about types are guaranteed to hold for
values at runtime. - The Hack type-checker's type system is unsound. HHVM uses a separate, sound
type system for JIT compilation. - HHVM represents Hack values as a tagged union: a 1-byte DataType enum, which
can be used to interpret an 8-byte Value. The Value may be a pointer (e.g.
strings, objects), an int or bool value, or unused (e.g. for null). - In the interpreter, we implement Hack bytecode by doing casework on the value
of this DataType.
Exercises
- What "secret" DataTypes exist in HHVM but are invisible to Hack? What do you think "KindOfUninit" is?
- Extend your modified tvAdd so that, if at least one of the two inputs is a string, it uses the logic for Hack "string casts" (the syntax:
(string)$x) to cast the other input to a string, then concatenates the results.- String casts have an associated bytecode, "CastString". That means that you can search bytecode.cpp for "iopCastString" to see how string cast is implemented, and share that logic.
- Test your work by modifying the file to use "add" to concatenate a string and an int, or a string and a float. Does it work? If so, congratulations: you have just implemented dime-store JavaScript.
- Enable the JIT again. How do the results of the "concat.php" file change?
- You may be surprised to see that even with the JIT enabled, we still use "+" for string concat - or do we?
- Enable the JIT and also enable the "TRACE=printir:1" debug output from the previous lesson. Look at how many times we compile "add", and for which types. Does the string version of "add" explain your results?
- In C++, we can ask for the "sizeof" a given struct, in bytes. Take a look at this example, where we use static_assert (checked at compile time) to check that different types have the sizes we expect: https://godbolt.org/z/bP4xTPMPq
- Why is a DataType 1 byte?
- Why is a Value 8 bytes? Does it matter that a bool can fit in 1 byte?
- How large is a TypedValue? Add static_assert(sizeof(TypedValue) == ...); to confirm.
- Why is a TypedValue that large? (Hint: read http://www.catb.org/esr/structure-packing/)
Hphp Doc Hands On Lesson2 (`hphp/doc/hands-on/lesson2.md`)
Lesson 2: Understanding Hack bytecode
Lesson goals:
- Learn how we evaluate Hack expressions using a stack machine.
- See how we implement operators, function calls, and array and object constructors in bytecode.
- Consider how alternative bytecode sequences could result in performance improvements.
- Write Hack code that compiles to specific bytecodes by reading our documentation.
Step 0: Pull, update, and re-compile HHVM
As in the previous lessons, let's start by re-compiling HHVM as
we review some concepts.
Bytecode for a stack machine
Hack bytecode is a linear sequence of instructions that implements the logic of
a Hack program. The main challenge of compiling Hack source code to bytecode is
to flatten an abstract syntax tree (AST) into this form. Compiling for a stack
machine is one natural way to do this flattening.
Let's work through an example. Here's the Hack expression that we're going to
compile to bytecode:
$a * f($b, 2, 'x') + $cWe're going to assume that we have a working parser for this language, so we
can parse this expression into a tree format. Furthermore, we're going to
assume that this parser handles order of operations for us, so that the root
node of this tree is the "add" operation, even though that operation comes
second. (Note that the root node of the tree is the last expression
evaluated!) Here's an AST for this expression:
Add
/ \
Mul $c
/ \
$a Call: f
/ | \
$b 2 $xOur goal is to produce Hack bytecode that will push the result of an expression
on the "stack". Here, the stack is an abstract construct of HHVM's virtual
machine. It does not necessarily correspond to a computer's native stack, and
depending on how we compile a function to machine code, we may even optimize
away all access to this stack! However, we can define HHVM's behavior in terms
of this abstract stack.
Let's call the function that produces bytecode for an AST node
"CompileToBytecode". We can implement this function recursively. In pseudocode,
that might look like:
def CompileToBytecode(ast_node, bytecode):
if not ast_node.children:
bytecode.push(CompileLeaf(ast_node.leaf))
return
for child of ast_node.children:
CompileToBytecode(child)
bytecode.push(CompileOp(ast_node.op))It's funny: whenever I say I'm going to write some pseudocode, what comes out
is valid Python.
Evaluating leaf nodes
Let's examine this pseudocode. First off, note that there's a distinction
between "leaf" and "internal" nodes – the blue and green nodes in our diagram
above, respectively. Leaf nodes are primitive expressions that don't take any
stack inputs. $a is a leaf node, because pushing $a on the stack is
accomplished with the CGetL bytecode:
CGetL <Local ID>: push the local with ID <Local ID> onto the stackCGetL does take an input—the ID of the local to push—but this input is a
constant, so it's part of the bytecode, not a stack input! In the compilers
world, we often refer to the constant inputs of a low-level instruction as
"immediates". CGetL is a bytecode that takes one immediate - a local ID - and
no stack inputs, and pushes that local onto the stack.
The constant expressions 2 and "x" can similarly be evaluated by bytecodes with
no stack inputs. Again, these bytecodes have immediates - the constant integer
and the constant string to push, respectively. We have:
Int <constant int>: push this integer onto the stackand:
String <constant string>: push this string onto the stackThese three bytecodes - CGetL, Int, and String - are sufficient to cover all
the leaf nodes in our example. Now, let's move onto the internal nodes. The
second half of our CompileToBytecode deals with these nodes.
Evaluating internal nodes
We've already seen an example of an internal nodes in the previous lesson: the
"Add" bytecode. This bytecode pops its two inputs from the stack, does
"whatever + does in Hack" for those two inputs, and pushes the result into the
stack. To evaluate (expr_1) + (expr_2) it suffices to recursively evaluate
the two expressions (putting each result on the stack), then execute an "Add"
bytecode.
"Add" and "Mul" are simple examples of internal nodes, in that they consume a
fixed number of stack inputs and take no immediates. Other nodes introduce a
few wrinkles to this basic pattern:
- Some nodes take immediates. For example, "IsTypeC" (used to evaluate
(expr) is TYPE) takes one input—the expression—from the stack, but takes the type
as the immediate. - Some nodes take a variable number of arguments from the stack. For example,
"NewVec" (used to evaluate vec[(expr_1), (expr_2), ... (expr_N)]) takes the number of inputsNas an immediate, popsNvalues
from the stack, and pushes the vec constructed from those values. - Some nodes take additional, hidden stack inputs in addition to the syntactic
inputs from the original Hack source.
We'll examine a function call node—the "Call" node in our example above—which
includes of the complexity above. There are several bytecodes that deal with
function calls in Hack. We support calls to free functions, instance methods,
and class methods, with static and dynamic versions of each, so we need at
least six function call bytecodes. There are several immediates used by all of
these cases, so we package them all up into "FCallArgs":
struct FCallArgs {
uint32_t numInputs;
uint32_t numOutputs;
// Other fields for async calls, inout args, coeffects, etc.
};The two most important bits in this struct are the "numInputs" and "numOutputs"
fields, which tell us how many arguments the call bytecode will pop from the
stack, and how many return values it will push. (We model functions with inout
arguments as pushing those results as additional return values, but for any
"normal" function call without such arguments, numOutputs will equal 1.)
The remaining immediates are specific to each type of function call. They
identify which function is being called. Hopefully, the fact that we have
multiple bytecodes for calls makes sense, because we must take different inputs
to identify the "receiver" of free functions, instance methods, and class
methods:
- A free function is identified by its string name.
- An instance method is identified by an object to call the method on and the name of the function to call.
- A class method is identified by a class pointer to call the method on and the name of the function to call.
The bytecode for a static call to a free function, FCallFuncD, takes two
immediates: FCallArgs, and a constant string function name. It has the
following signature:
FCallFuncD <FCallArgs> <constant string>:
Call the function with the given string name, popping FCallArgs.numInputs arguments
from the stack and pushing FCallArgs.numOutputs return values to it.The final wrinkle for these function calls is that they require—for now!—two
stack inputs before the syntactic arguments to the function. These inputs only
serve to pad the stack in memory with enough space for a machine-level
function call frame (also
called an "activation record" or "ActRec").
The fact that a call takes two hidden stack inputs is contingent on the
current size, in bytes, of HHVM's in-memory representation of an ActRec.
In particular, stack slots occupy 16 bytes in memory—each one is a
TypedValue!—and an ActRec occupies 32 bytes. It's rare for Hack bytecode to
be sensitive to low-level implementation details, but in this case,
unfortunately, it is.
To handle these hidden inputs, we have to modify our pseudocode above to
account for them in its "children" loop. In particular, for any call bytecode,
the hidden inputs are pushed first, before the syntactic arguments.
Putting it together
Let's run our pseudocode on the AST above. Here's the execution trace, showing the recursive calls, in order:
- Compile(Add):
- Compile(Mul):
- Compile(Leaf $a) => CGetL $a
- Compile(Call):
- Push ActRec padding => 2x NullUninit
- Compile(Leaf $b) => CGetL $b
- Compile(Leaf 2) => Int 2
- Compile(Leaf 'x') => String "x"
- Push the op => FCallFuncD {3, 1} "f"
- Push the op => Mul
- Compile(Leaf $c) => CGetL $c
- Push the op => Add
- Compile(Mul):
Remember that the algorithm produces a linear bytecode sequence via this
in-order tree traversal. Here's the result. Try stepping through it, and verify
that it computes the result of $a * f($b, 2, 'x') + $c and leaves it on the
stack!
CGetL $a
NullUninit
NullUninit
CGetL $b
Int 2
String "x"
FCallFuncD {3, 1} "f"
Mul
CGetL $c
AddStep 1: Examining basic expressions
In this section, we're going to look at the actual bytecode that HHVM generates
for different cases. There are a few hundred distinct bytecode operations to
keep track of. Luckily, these operations are all documented in a file:
Let's start by double-checking our prediction for the example above. Do so by
creating Hack script that evaluates the given expression and using the
"Eval.DumpHhas=1" debug flag. Put the following code into ~/php/bytecode.php:
<?hh
function test($a, $b, $c) {
return $a * f($b, 2, 'x') + $c;
}Then run this code with:
hphp/tools/hhvm_wrapper.php --hdf Eval.DumpHhas=1 ~/php/bytecode.php | grep -v srcloc(The last part of this command filters out "srcloc" annotations in the
generated bytecode, which attribute bytecode back a file and line in the Hack
source.) If we run this command on our test file, we'll get a compilation of
"test" which is basically the bytecode needed to compute our example
expression. The only additional bytecode is a RetC, which turns an expression
into a return statement: it pops one element off the stack and returns that
value to the caller. Here's a (slightly edited form) of what I get:
.function{} (3,5) <"" N > test($a, $b, $c) {
NullUninit
NullUninit
CGetL $b;"b"
Int 2
String "x"
FCallFuncD ... 3 1 ... "f"
CGetL2 $a;"a"
Mul
CGetL $c;"c"
Add
RetC
}This bytecode is quite close to our prediction above. There are a few more
components to the FCallArgs struct than what we showed, but that's expected;
Hack supports a variety of special ways to call functions. A bigger difference
is that $a is pushed onto the stack after the function call result, and with
a different op—CGetL2, instead of CGetL.
Wait, what the heck? Shouldn't HHVM evaluate these expressions in order? A
compiler may re-order expression evaluations as long as doing so has no
observable effect. For example, we generally can't re-order function calls,
because function calls could have side effects like writing to the heap,
throwing errors, or doing IO. Pushing a local onto the stack is side-effect
free, so in this case, this rewrite is safe.
We should check that this bytecode sequence has the same behavior as the one we
predicted. To do so, we need to understand the semantics of CGetL2. There are a
few ways to find out what a bytecode operation does. You can read:
- Its docs, by searching for CGetL2 in bytecode.specification.
- Its implementation in the interpreter, by searching for "iop$NAME" (i.e. iopCGetL2) in bytecode.cpp.
- Its implementation in the JIT, by searching for "emit$NAME" (i.e. emitCGetL2) in the JIT directory.
Here are the docs for CGetL2. (We skip the part about throwing errors, which will not happen in our example.)
Get local as cell. If the local variable given by %1 is defined, this
instruction gets the value of the local variable, pushes it onto the stack as
a cell, and then pushes $1 onto the stack.In this explanation, %1 refers to the first immediate—that is, the local
ID—and $1 refers to the top element of the stack. Basically, the docs are
saying that CGetL2 pops one element off the stack, pushes the local, then
pushes the popped element on top of it. The net result is that doing CGetL2
after evaluating an expression is equivalent to doing CGetL before evaluating
it.
Step 2: Examining more complex expressions
Let's try out a few more expressions with our setup from above. Try this version:
<?hh
class C {}
function test_vec() {
return vec[17, new C()];
}
function test_dict($x) {
return dict['a' => 17, 'b' => $x, 'c' => 'd'];
}
function test_concat($x) {
return 'prefix'.(string)$x.'suffix';
}Here's what I get for these examples. Make sure these outputs make sense. Other
than the complex logic needed to implement the "new C()" syntax, these outputs
are an exact match for what our pseudocode would predict.
.function test_vec() {
Int 17
NewObjD "C"
Dup
NullUninit
FCallCtor ... {0, 1} ...
PopC
LockObj
NewVec 2
RetC
}
.function test_dict() {
Int 17
CGetL $x
String "d"
NewStructDict <"a" "b" "c">
RetC
}
.function test_concat($x) {
String "prefix"
CGetL $x
CastString
Concat
String "suffix"
Concat
RetC
}These examples show us a couple of new bytecodes used to implement these basic
elements of Hack syntax:
NewVec 2pops that two elements from the stack, appends them to a new vec,
and pushes that vec.- "new C()" expands to several bytecodes - from
NewObjD "C"toLockObj-
that split up the operations of allocating the object and calling the
constructor. That makes sense, because calling the constructor may require
evaluating further expressions (the constructor arguments). NewStructDict <"a" "b" "c">is similar to NewVec, in that it's a bytecode
with a variable number of inputs. It takes its values from the stack, but
the keys are a vector of string immediates.- Concatenation happens two inputs as a time - at least, if done with
Concat
bytecodes.
Lesson summary
- Hack bytecodes operate on a stack machine. Most bytecodes pop inputs from the
stack and push an output to it. - We can compile an expression to bytecode recursively, by compiling its
subexpressions, then appending a bytecode that applies the top-level
expression transformation to those inputs. - Bytecodes take "immediates" - constant arguments that are part of the
instruction stream - as well as stack inputs. - Because Hack is a high-level language, it includes bytecodes for constructing
and operating on complex data structures like objects, strings, vecs, dicts,
and keysets. You can see a full list of bytecodes here.
Exercises
Search bytecode.specification for other bytecodes related to string concatenation.
- Is there an alternative bytecode sequence that we could use to implement
test_concat? - Which bytecode sequence would you expect to be faster? Why? (Reading the interpreter may help in this case.)
- Is there an alternative bytecode sequence that we could use to implement
Rewrite
test_dictfrom our last example as follows:
function test_dict($x, $y, $z) {
return dict['a' => 17, 'b' => $x, 'c' => 'd', $y => $z];
}- How do we implement this modified dict constructor in bytecode?
- Are there performance differences between this approach and NewStructDict?
- Can you provide an alternative bytecode sequence for this expression that could improve its performance?
Investigate how HHVM handles "member operations"—basically, read/write access
into array elements or object properties. Take a look at the bytecode for these
functions:
function prop($x) {
$x->y = 17;
}
function elem($x) {
$x['y'] = 17;
}
function nested_one_level($x) {
$x['y']->z = 17;
}
function nested_two_levels($x) {
$x['y']->z[] = 17;
}- How are we handling these sequences of operations in a compositional way?
- Read the relevant bytecode docs in bytecode.specification, and write Hack
functions that use the bytecodes BaseH, BaseSC, SetOpM, and UnsetM. Confirm
with the Eval.DumpHhas output. - What changes if we replace these mutating ops with "read-only" member-ops
likereturn $x['y']->z[17]; - What state do we need to keep in between steps of a nested member-op write
sequence? Take a look at struct MInstrState in this file
to confirm your guess.
Hphp Doc Hands On Lesson3 (`hphp/doc/hands-on/lesson3.md`)
Lesson goals:
- Explore a unique feature of the PHP and Hack languages: value-type strings and array-likes.
- Learn about how HHVM uses reference counting to efficiently implement value types and to reclaim memory.
- See how certain Hack bytecodes result in refcounting operations.
- See how HHVM uses an ahead-of-time compilation step to reduce unnecessary refcounting.
- Make and test a small attempt at a performance optimization.
Step 0: Mysterious missing mutations
Save the following Hack code in ~/php/refcounting.php:
<?hh
class C { function __construct(public int $x) {} }
function mutate_int(int $x) {
$x = 34;
}
function mutate_object(C $c) {
$c->x = 34;
}
function mutate_dict(dict<string, int> $d) {
$d['x'] = 34;
}
<<__EntryPoint>>
function main() {
$x = 17;
mutate_int($x);
var_dump($x);
$c = new C(17);
mutate_object($c);
var_dump($c);
$d = dict['x' => 17];
mutate_dict($d);
var_dump($d);
}What would you expect to see if you ran this code? Make a guess before reading on.
...
...
...
...
...
...
...
...
...
...
...
...
...
...
...
...
Okay, let's run the file. I assume you already have HHVM compiled at this point! Run:
hphp/tools/hhvm_wrapper.php ~/php/refcounting.phpHere's what you should see as output:
int(17)
object(C) (1) {
["x"]=>
int(34)
}
dict(1) {
["x"]=>
int(17)
}What is going on here? The caller main() sees the modification made to $c
in mutate_object(), but it doesn't see the modification made to $d inmutate_dict(). Clearly, HHVM is broken. It's a compiler bug! Shut it down!
...no. This result is correct. In PHP and Hack, objects are "passed by
reference" or are "reference types", but strings, vecs, dicts, and keysets are
"passed by value" or are "value types". Primitives like ints, floats, booleans,
and the value "null" are also value types.
Vecs, dicts, and keysets share many similarities. They're heap-allocated
containers of varying size with value-type semantics. We use the term
"array-likes" throughout our codebase to refer to this trio of types.
When we pass an object as a function argument, we're passing a reference to a
unique value. Mutations to that object are visible to anyone who has a
reference to it. On the other hand, when we pass a value type as a function
argument, the caller's copy and the callee's copy of that value are logically
distinct. Mutations made to a value type inside a function are not visible
outside it.
Now, it's not surprising that primitives like ints are value types. Indeed, any
other implementation would be quite inefficient! As we saw in Lesson 1,
ints, floats, and booleans are stored directly in the TypedValue—they aren't
hidden in heap-memory behind a pointer. When we push $x onto the stack to
call mutate_int(), the value on the stack is independent of the value in the
local.
It's also not surprising that objects are reference types. Again, from Lesson
1, we saw that objects are heap-allocated. All that we store in a TypedValue is
a pointer to this heap memory; the object's class and its properties are stored
in the heap. When we push the object $c onto the stack, we're copying the
pointer, not the object! As a result, when we write through this pointer to
modify the object in mutate_object(), the effect is visible in main! (Ifmutate_object() first replaced $c with a new object - i.e. $c = new C()
- then modifications to this value would not affect the original one.)
The surprising bit here is that in PHP and Hack, strings and array-likes are
heap-allocated value types. Even though these values are represented as a
pointer to the heap, when we pass them to a function, the two pointers act like
distinct values. Take a moment to think about how you might implement this
behavior. One straightforward approach is to copy these values when we pass
them as function arguments, but that would be a performance disaster. For
example, it would turn Shapes::idx from a simple O(1) hash table lookup into an
O(n) dict copy! We need a better way.
Enter refcounting
HHVM implements efficient heap-allocated value types using reference counting.
In fact, all heap-allocated values are refcounted, regardless of whether
they're reference types or value types. Refcounting serves two purposes:
- It's a way to eagerly free heap-allocated values. We also use a
mark-sweep garbage collector
to handle cycles. - It enables copy-on-write (COW)
for strings and array-likes, allowing us to pass them as arguments in O(1) time.
The first purpose applies to objects, strings, and array-likes. Like Java and
Python, and unlike C++, Hack automatically manages memory for its users. It
frees heap-allocated values that are no longer used. Refcounting is one
approach to automated memory management: if a heap-allocated value has no
references, we can free its memory. The second purpose only applies to
heap-allocated value types, i.e. strings and array-likes.
Refcounting is not a complete solution to memory management!
Read "Moon instructs a student" to see why.
Let's look at how we implement refcounting. In our code, every heap-allocated
value starts with an 8-byte header, the C++ struct HeapObject. This struct is
defined in the file header-kind.h.
(Don't ask why. The reason has been lost to the mists of time.) Thus, we can
say that even though strings and array-likes are not Hack objects, all
heap-allocated values are HeapObjects.
Be careful with the terminology! A HeapObject is any heap-allocated value. It
can be a reference type—e.g. an object—or a value type—e.g. a string. We will
always use "object" to refer to Hack objects, and "HeapObject" to refer to the
union of Hack objects, strings, and array-likes.
Every HeapObject tracks a reference count: a count of the number of
references to that value that are currently live. These references might be
locals or stack elements. If a HeapObject appears in a vec, the vec holds a
refcount on the HeapObject. If a HeapObject is stored to an object's property,
then the object holds a refcount to the HeapObject. A HeapObject's refcount
stores the number of distinct places that have a pointer to that HeapObject.
That's not actually true! There are some cases where HHVM skips decrementing
refcounts, making the refcounts an overestimate. The real constraint is that
a HeapObject's refcount is an upper bound on the number of distinct places
that have a pointer to that HeapObject. But let's ignore this subtlety.
Every time we copy a HeapObject pointer—for example, if we push a copy of a
HeapObject local onto the stack—we increment the HeapObject's refcount
("inc-ref"). Every time we overwrite or let go of a HeapObject pointer—for
example, if we overwrite a HeapObject local, or if we pop one off the stack—we
decrement its reference count ("dec-ref").
Using refcounting for memory management is simple. Every time we dec-ref a
HeapObject, we check if its refcount is 0. If it is, then we know that that
object will never be used again, and we can free its associated memory.
Using refcounting to implement efficient heap-allocated value types is a bit
more involved. Whenever we modify a value-type HeapObject, such as a dict, we
use a strategy called copy-on-write:
- If the dict has refcount == 1, we modify it in place. No one besides us has a
pointer to the dict, so we're still maintaining value-type semantics, despite
doing O(1) in-place edits. - If the dict has refcount != 1, we copy it (to get a copy with refcount == 1)
and the modify the copy. We dec-ref the old value since we've replaced our
pointer to it with a pointer to the copy.
Refcounting and bytecode
The stack machine semantics of a Hack bytecode imply refcounting bookkeeping
that we have to do to implement it.
We can figure out how to do this refcounting by asking ourselves a question: if
the inputs to a given bytecode are refcounted HeapObjects, how many pointers to
those objects exist after the operation is complete?
Let's start with the simplest case, a CGetL bytecode. From
bytecode.specification:
CGetL <local variable id> [] -> [C]
Get local as cell. If the local variable given by %1 is defined, this instruction
gets its value and pushes it onto the stack. If the local variable is not defined,
this instruction raises a warning and pushes null onto the stack.This bytecode takes a local and pushes it onto the stack. If the local is a
HeapObject, then there is now one additional copy of that heap pointer. In
addition to pushing a value on the stack, CGetL must inc-ref it.
PushL is a related bytecode that "pushes" a value onto a stack instead of
"getting" it. The difference is that PushL both gets the value of the local on
the stack and unsets the local itself (i.e. sets the local to Uninit, which is
Hack's flavor of "undefined").
PushL <local variable id> [] -> [C]
Teleport local value to eval stack. The local given by %1 must be defined.
This instruction pushes the local's value on the stack, then unsets it,
equivalent to the behavior of a CGetL / UnsetL pair.If the local that we pushed with PushL is a HeapObject, then we've gained
another copy of that heap pointer on the stack, but we've unset the pointer in
the local. Overall, its refcount is unchanged. Because PushL "moves" a heap
pointer, it does no refcounting.
Let's look at one more case, an operation that uses a stack value to set a local:
PopL <local variable id> [C] -> []
Teleport value from the stack into a local. This instruction stores the top
value of the stack $1 to the local %1, then pops this value off of the stack.Let's say the value on top of the stack was a HeapObject. As with PushL, we're
gaining one reference to it (the new value of the local), but we're also losing
one reference to it (the one we pop off the stack). We do not need to inc-ref
or dec-ref the stack value. However, there is refcounting associated with PopL:
if the old value of the local was a HeapObject, we must dec-ref it! PopL does
not need to inc-ref or dec-ref the stack value that it's storing to a local,
but it must dec-ref the local's old value.
Now that you've seen these examples, predict the refcounting needed for these bytecodes:
- UnsetL: "unsets" a local by setting its value to Uninit.
- NewVec: pops n values off the stack and creates a vec containing those elements.
- RetC: pops and returns the top value of the stack, cleaning up the function's frame.
- Concat: pops two values off the stack; if they're not both strings, it throws, else, it concatenates them and pushes the result.
...
...
...
...
...
...
...
...
...
...
...
...
...
...
...
...
If UnsetL is operating on a refcounted local, it must dec-ref the old value.
UnsetL dec-refs the local's old value.
NewVec can "move" any HeapObjects it's taking as stack inputs. A stack slot
used to refer to them; now, a vec element refers to them; the overall
refcounting effect is neutral. However, NewVec allocates a new, refcounted vec
and pushes a pointer to it onto the stack. This new vec should have a refcount
equal to 1. Constructors like NewVec create a new HeapObject with a refcount
of 1.
RetC signifies a function return. It "frees" the memory associated with the
function's frame. (Frames are usually stack-allocated, so freeing the memory is
just decrementing the stack offset.) Functions may hold references to
HeapObjects in their locals, so before we "free" the frame, we must dec-ref
these values. RetC dec-refs the function's locals.
Let's assume Concat is operating on two refcounted strings. Concat should
allocate a new string, so it's acting kind of like a constructor; the new
string will have a refcount of 1. Concat also pops two strings off the stack,
so it must dec-ref those. In general, stack-only bytecodes like Concat
dec-ref their inputs and either allocate a new output with refcount 1, or
inc-ref an existing value to push it as an output.
Step 1. Visualizing refcounting
When we run HHVM in interpreted mode, we can turn on debug tracing to see refcounting working as above. Put the following code into ~/php/refcounting.php:
<?hh
class LinkedList { function __construct(public $next = null) {} }
<<__EntryPoint>>
function main() {
$x = new LinkedList();
$y = new LinkedList($x);
$z = $y;
$y = 17;
var_dump($z);
$z = 34;
}Then, run this code in interpreter mode with refcounting debug enabled:
TRACE=bcinterp:9 hphp/tools/hhvm_wrapper.php -i ~/php/refcounting.phpThe resulting output shows the types and refcounts of all locals and stack values at each bytecode. For example, just prior to the final RetC that ends the function main(), we have:
dispatch: __
dispatch: || === Stack at /home/kshaunak/php/refcounting.php:13 func main ===
dispatch: || {func:main,callOff:0,this:0x0}<C:0x7f06b903e320:c(1):Object(LinkedList) C:0x11 C:0x22> C:Null
dispatch: \/
dispatch: 71: RetCThis output says that in function main(), local 0 (which is $x) contains a
LinkedList with refcount 1 at heap address 0x7f06b903e320. The other two
locals, $y and $z, are the integers 0x11 and 0x22, respectively, because we
assigned to them earlier. There's one value on the stack, a null, which is the
value that the RetC will return.
Look through this output and see how LinkedList refcounts change with CGetL /
SetL bytecodes. Confirm for yourself that the refcounting semantics match our
predictions above. What is the maximum refcount we see for any object in this
function? Which object has that refcount, which bytecode does it occur at, and
where are all of the references to it?
Step 2: Static strings and array-likes
Strings and array-likes are value types. A bit of Hack code that has a
reference to one of these values can guarantee that no other Hack code can edit
that value. HHVM implements heap-allocated value types by storing a refcount in
their heap data, and blocking mutation for value types with a refcount that is
not equal to 1.
This constraint allows HHVM to make a critical optimization: placing strings
and array-likes in shared memory. Most Hack objects are thread-local. HHVM can
run multiple requests concurrently, and a given object is only referenced on a
single thread: the thread that allocated it. But this approach would be a
disaster for commonly used values like the empty vec or empty dict! Instead,
we'd like to be able to share these values between requests. Multiple requests
would all have concurrent access to a single empty vec and dict.
Here's an approach to sharing these values that does NOT work:
- Store a single, empty vec in a process global with an initial refcount of 1.
- When we retrieve this vec on a request, we inc-ref it, so it starts with a
refcount of at least 2. We inc-ref and dec-ref it as normal when executing
bytecode for each request. - Because the minimum refcount of this vec, as seen from any Hack code, is 2,
no one will ever modify it.
This approach seems to work. Whenever Hack code is manipulating this global
vec, it will have a refcount of at least 2 (one held by the global itself, and
one held by the value in the request), so it can't be modified. The bug here is
subtle: modifying any value in shared memory introduces a race condition,
unless done with atomic memory operations. The bug here is that we can't
actually inc-ref or dec-ref any values in shared memory!
Here's an example of a race condition that can occur when inc-ref-ing the
shared vec:
- Thread A loads its initial refcount X.
- Thread B loads its initial refcount X.
- Thread A writes back the incremented refcount X + 1.
- Thread B writes back the incremented refcount X + 1.
Even though threads A and B have created two new references to the shared vec,
the refcount has only increased by 1. At some point, when these threads dec-ref
the pointers they hold, they can dec-ref the value to 0 and "free" the array,
causing HHVM to crash!
Instead, here is how HHVM implements shared-memory strings and array-likes:
- The refcount of a string or array-like can be either some positive or
negative value. - If the refcount is positive, the HeapObject is thread-local; if it is
negative, it is in shared memory. - When doing any inc-ref or dec-ref op on a string or array-like, we first
check the refcount's sign. If it's negative, we do nothing!
Since concurrent reads on shared memory are safe, this approach doesn't
introduce any race conditions. We can share a single empty vec and empty dict,
and many other constant HeapObjects, between all of our requests! Let's look at
a quick example. HHVM places any "constant value" like a constant string or vec
into shared memory. Put the following code into ~/php/static.php:
<?hh
<<__EntryPoint>>
function main() {
$x = vec[17, 34];
var_dump($x);
$y = 'my constant string';
var_dump($y);
}Then run it with:
TRACE=bcinterp:9 hphp/tools/hhvm_wrapper.php -i ~/php/static.phpWhen we manipulate $x and $y as locals and as stack values, you should see
a refcount of "c(static)" for them: a sentinel negative refcount value. HHVM's
tvIncRefGen and tvDecRefGen, the generic refcounting helpers, avoid mutating
static strings and array-likes by checking that the refcount is positive before
decrementing it. The refcount of a static string or array-like will never
change!
You might ask, why don't we use atomic increments or decrement operations
like "lock-add" to inc-ref and dec-ref all values? Doing that would save a lot
of instructions: a compare-and-branch on every refcounting op. Unfortunately,
it would also tank performance.
On x86-64, in order to write to any memory, a core must take exclusive access to that memory address
(and to the whole cache line containing that address). That means that
inc-ref-ing a shared value is essentially single-threaded.
As compiler engineers, we should be wary of relying on delicate invariants like
"the refcount of a static value is never mutated". Luckily, we have an ace up
our sleeve to help us enforce such guarantees: the debug-mode build! The
refcounts that are 1 less and 1 greater than the "StaticValue" refcount are
considered to be invalid refcounts. In debug mode, if we ever see a heap object
with a negative refcount that's close to, but not equal to, "StaticValue", we
crash with a nice error message.
Assertions are a critical tool for testing our code. We check these
assertions in debug builds, then compile them out in release builds where
performance is a top priority. In HHVM code, the "assertx" helper checks an
assertion and prints a stack trace and other diagnostics on failure.
Structure your code so that clear invariants hold, then use assertions to
check them wherever possible!
Step 3: Refcounting optimizations
As we've seen before, a compiler like HHVM is free to diverge from the
"trivial" implementation of a given bytecode, as long as the logic that HHVM
executes matches the stack machine in any observable behavior. In the previous
lesson, HackC and HHVM used this freedom to re-order some bytecodes, but the
resulting logic was not meaningfully different than the trivial implementation.
Now, we'll look at how we can tweak compilation to gain performance.
Refcounting is expensive, because it requires accessing heap memory. Memory
access is one of the most expensive operations on modern CPUs - far more
expensive than doing arithmetic on values in hardware registers. As a result,
saving inc-refs or dec-ref ops is usually a nice win. Furthermore, an inc-ref
and dec-ref on the same value cancel out! The "PushL" bytecode uses this fact
for optimization. Verify for yourself that these bytecode sequences have the
same semantics:
- CGetL $local; UnsetL $local
- PushL $local
If we execute the first sequence the "trivial" way, then we'll inc-ref the
local for CGetL (because its value is now in the local AND on the stack), then
immediately dec-ref it (in order to overwrite it with Uninit). Because PushL
does the two ops together, we can cancel these two operations - both when we're
interpreting Push, and when we're JIT-compiling it. HackC does not directly
emit PushL bytecodes, but HHVM comes with an ahead-of-time bytecode optimizer
called HHBBC that does these rewrites. (HHBBC even adds UnsetL ops for locals
that are "dead" - unread by later code in a function - in order to create more
optimizable sequences.)
Remember that HHVM converts, or "lowers" bytecode to machine code in stages.
It compiles bytecode to intermediate representation (HHIR), then IR to virtual
assembly (vasm), then vasm to machine code. Most optimizing compilers implement
similar optimizations at multiple stages. For example, HHBBC optimizes
bytecode; it replaces bytecode sequences with equivalent sequences with better
refcounting properties. But if we lower bytecode to IR that does an inc-ref
followed by a dec-ref of the same value, we have IR level optimizations (see:
refcount-opts.cpp) that can cancel those ops as well!
Now, let's try to make our own refcounting optimization! Because HHVM has a
garbage collector (GC), we can skip dec-refs without changing the result of any
Hack operations. Taken to an extreme, we could modify HHVM's DecRef code
generation to return immediately, so dec-refs are a no-op at the machine code
level! At this point, we'd leak memory to the GC all over the place, but
refcounting would be a lot faster!
Does this change affect any observable behavior? It turns out that the notion
of "observable" is a bit fuzzy! If we leak this much memory, there's a good
chance the teams monitoring HHVM on live webservers will see a large
increase in memory used. Worse still, the OS's OOM killer (which kills
processes that use too much memory) might kill HHVM servers. Leaking too much
memory is an observable, but leaking a small amount may be okay.
We're going to try a less extreme version of this optimization. The Idx
bytecode takes an array, a key, and a default value as stack inputs. It does
the array access and pushes the result onto the stack, pushing the default
value if the key is not present in the array. Idx must inc-ref the stack output
and dec-ref the stack inputs.
In our codebase, the code that lowers a bytecode $NAME to IR is in a function
called emit$NAME. So, we can find the code to compile Idx by searching for Idx
in the hphp directory. It turns out that it's in
irgen-builtin.cpp.
- Modify emitArrayIdx to avoid dec-ref-ing any of the inputs. (It's sufficient to modify the vec and dict cases.)
- Write a small test file using idx() to confirm that your modification is
working. You can look at the TRACE=printir:1 output to see that it has fewer
dec-refs than before (compare the output between your modified HHVM and the
output from release HHVM). As a shortcut, pass-b /usr/local/hphpi/bin/hhvm
to hhvm_wrapper.php to use release HHVM without recompiling! - Kick off local unit tests for your code:
hphp/test/run quick slow
When you have these steps working, you'll have saved a small amount of
instructions, loads and stores at the cost of a significant memory penalty.
Is this tradeoff an overall CPU win? Only data can tell - but my guess is,
it's a regression! In the next lesson, we'll try to find a performance win.
Lesson summary
- Hack has multiple heap-allocated types: objects, strings, and array-likes
(vecs, dicts, and keysets). - Of these types, objects are reference types, while strings and array-likes
are value types. A mutation to an object is visible to everyone with a
reference to it. On the other hand, two references to a string behave like
distinct values. - HHVM uses refcounting for two purposes: to implement copy-on-write value
types, and to eagerly free heap values. - HHVM uses a negative refcount to indicate that strings and array-likes are in
shared memory. This trick allows us to de-duplicate "static", or constant,
strings and array-likes across requests. - Because Hack bytecodes can create new references to heap values, HHVM must do
inc-refs and dec-refs to implement these bytecodes. For example, we inc-ref the
stack outputs of a bytecode and dec-ref the stack inputs.
Exercises
- Remember that HeapObjects include reference types like objects as well as
value types like strings and array-likes. Why doesn't HHVM support "static"
(shared-memory) objects? - Take a look at HHVM's tvDecRefGen, which does a generic dec-ref of a
TypedValue. Note that this helper can be used on any TypedValue, including
non-heap-allocated values like integers.- How many cases does tvDecRefGen have to handle? Confirm by reading the comments in decref-profile.h.
- Is it safe to use tvDecRefGen to dec-ref an object?
- Is it optimal to use tvDecRefGen to dec-ref an object? What could we do instead?
- Modify your code for the Idx bytecode to skip the inc-ref of the new value.
- Why is the new code unsafe? Why is it okay to skip a dec-ref, but not an inc-ref?
- Commit this modified version run the tests with
hphp/test/run quick slow - If those tests are passing, you can also add other flags to the command:
a. To test "repo mode", pass "-r" to hphp/test/run. This flag turns on HHBBC.
b. To test optimized JIT compilation, add "--retranslate-all 2".
c. Typical HHVM CI will run a battery of tests including these flags.
Here is an hphp/test/run command that includes all of the flags above:
hphp/test/run -m jit -r --retranslate-all 2 hphp/test/quick/dict/basic-getters.phpOnce you can reproduce a failure, fix them by re-adding the inc-refs you
removed and confirm that the test now passes.
Hphp Doc Hands On Lesson4 (`hphp/doc/hands-on/lesson4.md`)
Lesson 4: Making an IR optimization
Lesson goals:
- Use profiling tools to find an optimization opportunity.
- Learn how to read printir tracing, the IR documentation, and JIT types.
- Improve IR generation to speed up casting collections (e.g. Vector) to arrays (e.g. vec).
- Move this code to "simplify", one of the IR-to-IR optimization routines, to make it more general.
Step 0: Suboptimal code generation
In this step, we're going to hunt for a potential performance win.
Our main tool in this hunt is profiling. One way to do that is to run
Linux perf
on a machine with an HHVM webserver under load. HHVM even outputs a
"perf-PID.map" file that can be used to symbolize stack traces in JIT-ed Hack
code. That said, it's easiest to read perf's numbers for C++ helpers, and many
performance opportunities in HHVM come from optimizing C++ functions that are
used to implement the Hack runtime.
In a sample perf trace I took, I saw that ~0.2% CPU is spent doing
object-to-array-like casts (e.g. convObjToVec, convObjToDict, convObjToKeyset).
What makes these helpers a good potential target for optimization? First off,
there is some opportunity there: if we could eliminate most of the cost of
these helpers, it could be a 0.2% gCPU win. Second, the opportunity is
relatively easy to realize. It's difficult to implement general-purpose code to
cast an object to an array-like. That's why HHVM implements these casts by
calling a C++ helper. But after having read enough of web code written in Hack,
I can make an educated guess that most object-to-array casts are casts on a
collections objects. These objects are secretly backed by arrays, which makes
it possible for us to generate faster code to do the cast in this case.
When we have a potential perf idea, it's important to write a small test case
and check that the opportunity shows up. Let's put the following code into
~/php/convert.php:
<?hh
<<__EntryPoint>>
function main() {
$x = Vector { 17, 34 };
var_dump(vec($x));
}Now, when we run this code with tracing enabled, we must do so at the highest
optimization level! If we don't, we might go down a rabbit hole trying to make
an optimization that is irrelevant for our production use case. To see how HHVM
compiles the code above, we have to run it with both HHBBC (the
bytecode-to-bytecode optimizer) and RetranslateAll (the two-phase JIT) enabled:
TRACE=printir:1 hphp/tools/hhvm_wrapper.php -c -r 2 ~/php/convert.php | less -RNThen we have to jump to the TransOptimize translation for main. Here's the
relevant part:
B4: [profCount=2] (preds B0)
--- bc main(id 1078142128)@5, fp 0, fixupFP 0, spOff 1, [profTrans=0]
ColFromArray Vector
(12) t5:Obj=HH\Vector = NewColFromArray<HH\Vector> vec(0x8018d4e0)=Vanilla
Main:
0x3280000e: mov $0x8018d4e0, %edi
0x32800013: callq 0xa712ff0 <HPHP::collections::allocFromArrayVector(HPHP::ArrayData*)>
0x32800018: mov %rax, %rbx
--- bc main(id 1078142128)@24, fp 0, fixupFP 0, spOff 3, [profTrans=0]
CastVec
(22) t6:Vec = ConvObjToVec t5:Obj=HH\Vector -> B5<Catch>
Main:
0x3280001b: mov %rbx, %rdi
0x3280001e: callq 0xa260c00 <HPHP::jit::convObjToVecHelper(HPHP::ObjectData*)>
-> B6
B6: [profCount=2] (preds B4)
(23) StStk<IRSPOff -4> t1:StkPtr, t6:Vec
Main:
0x32800023: movb $0x17, -0x38(%rbp)
0x32800027: movq %rax, -0x40(%rbp)The important thing here is that, even though we know that t5 is an HH\Vector
in instruction (22) ConvObjToVec, we still generate machine code for it that
makes a call to a C++ function convObjToVecHelper. We can do better!
Understanding IR tracing
We last looked at the TRACE=printir:1 output in the first lesson. Since then,
we've learned enough about HHVM to tease its output apart in more detail. This
tracing is useful because it simultaneously displays three levels of code
representation:
- The input Hack bytecode, documented in bytecode.specification
- HHVM intermediate representation, documented in ir.specification
- The output machine code, documented in an x86 manual
Here's the Hack bytecode annotation for the first IR op above. First, we see
the function and bytecode offset where this bytecode came from, a sort of
annotation from the bytecode back to source code. Then, we see the bytecode's
name and immediates:
--- bc main(id 1078142128)@5, fp 0, fixupFP 0, spOff 1, [profTrans=0]
ColFromArray VectorHere's the IR op. Like the bytecode, the IR op takes immediates - the
parameters inside the angle brackets. Unlike the bytecode, the IR takes inputs
and outputs inline. HHVM's IR operates on
static, single-assignment (SSA) temporary values.
For this op, the input SSATmp is a constant, so we hide its ID; the output
SSATmp is not constant, so we show that it's "t5", of type "Obj=HH\Vector". At
the IR level, we're no longer implicitly operating on a stack machine - all
stack operations are explicit!
(12) t5:Obj=HH\Vector = NewColFromArray<HH\Vector> vec(0x8018d4e0)=VanillaHere's the machine code that we generate for this IR op. Machine code can be
placed in multiple regions of memory, depending on whether we think blocks at
the machine code level are likely to be executed. If they are, we put the code
in "Main"; otherwise, we put it in "Cold" or "Frozen". The machine code for
this op is simple because the heavy lifting is done in C++:
Main:
0x3280000e: mov $0x8018d4e0, %edi
0x32800013: callq 0xa712ff0 <HPHP::collections::allocFromArrayVector(HPHP::ArrayData*)>
0x32800018: mov %rax, %rbxAfter the ColFromArray bytecode comes the CastVec bytecode. We compile this
bytecode to two IR ops. ConvObjToVec is doing the main logic of the Cast op,
and, again, is implemented mostly in C++. StStk ("store stack") pushes the
final result of the cast onto the stack. Unlike the other IR ops here, StStk is
compiled directly to machine code. It's simple enough that we don't need to
shell out to C++ for it.
Where is the rest of the code?
If we look at the bytecode for these operations, we'll see that the IR trace
above skipped several bytecodes. You can see for yourself by running this
command, which is like our command above, except that:
- We're adding in "grep -v srcloc" to filter out source location attribution.
- We drop
TRACE=printir:1and add--hdf Eval.DumpHhas=1to get a bytecode rather than an IR dump. - We drop
-r 2(which expands to--retranslate-all 2) because RetranslateAll results in more optimized IR generation - it doesn't affect the bytecode!
hphp/tools/hhvm_wrapper.php -c --hdf Eval.DumpHhas=1 ~/php/convert.php | grep -v srclocHere's what I get if I run that command:
.function{} [unique persistent "__EntryPoint"("""v:0:{}""")] (4,7) <"" N > main() {
.declvars _0;
Vec @A_0
ColFromArray Vector
AssertRATL _0 Uninit
AssertRATStk 0 Obj=HH\Vector
PopL _0
NullUninit
NullUninit
AssertRATL _0 Obj=HH\Vector
PushL _0
CastVec
FCallFuncD <SkipRepack SkipCoeffectsCheck> 1 1 "" "" - "" "var_dump"
AssertRATStk 0 InitNull
PopC
Null
AssertRATL _0 Uninit
RetC
}In the bytecode, in between creating the Vector and casting it to a vec, we
store it to local 0 (via PopL), then unset the local and push it back onto the
stack (via PushL). Check bytecode.specification to confirm your understanding!
There are also some type assertion bytecodes (AssertRATL, "assert
repo-authoritative type for a local", and AssertRATStk, for the stack), but it
makes sense that these bytecodes don't compile to machine code. They just add
in type information at compile time.
It makes sense that the bytecode stores the Vector to a local - after all, that's what the source code does! The question is, why don't the IR ops match the bytecode above exactly? If the IR were to implement the exact same operations that the bytecode above claims to do, then we'd have several IR ops for each bytecode in this sequence:
- ColFromArray would do the NewColFromArray, then use StStk to push that value onto the stack.
- PopL would use LdStk ("load stack") to load that value into an SSATmp, then use StLoc ("store local") to store it to a local. In general, PopL also needs to load and dec-ref the old value of the local first, but in this case, the AssertRATL above tells us that the local is already Uninit, which is not a refcounted type.
- PushL would use LdLoc ("load local") to load the value, then use StStk push it onto the stack. It would use StLoc to store an Uninit value to the local to "unset" it.
- CastVec would use LdStk to read that stack value, do the cast, and then use StStk to push the result.
Compiler engineers love abbreviations! "Loads" and "stores" are just reads
and writes to memory. Because interacting with memory is ubiquitous in Hack
code, we have tons of IR ops that use "Ld" or "St" in their names. We also
use shorthand for many other ops, like CreateAFWH, DecRefNZ, JmpZ, and Shr.
These names seem opaque at first, but they make reading code easier once you
are versed in the domain.
If you're ever confused about what an IR op does, consult ir.specification.
These are sensible and correct translations of the bytecode above into IR.
They're also wasteful. The ColFromArray, PopL, PushL, and CastVec are pushing
and popping a single Vector value. If the net effect of all of these ops is to
do a cast on the Vector's SSATmp, it's better if we just do that and skip the
intermediate stack and local stores and loads. HHVM produces this more
optimized code in two steps:
- It does a literal translation of each bytecode to IR, exactly as we did above.
- Then, it analyzes the resulting IR ops and sees which ones can be optimized away.
There are actually two places we do the analysis needed to make these
optimizations. First, the irgen ("IR generation") code for each bytecode uses a
forward-only analysis called FrameState,
which eliminates operations loads that can be trivially removed (e.g. a LdStk
that comes right after a StStk). FrameState can eliminate redundant loads in
straight-line code, but we need to apply a more complex fixed-point algorithm
to handle branches and loops.
load-elim and store-elim
do these optimizations in general, after irgen is complete.
Reading the IR spec
ir.specification is actually a kind of executable
documentation. We use this script
to process the lines beginning with | in this file to produce C++ code
defining a table of IR ops, the single source of truth for the IR. Because the
documentation is the implementation, it's always up to date! Let's look at a
few examples:
| ConvObjToVec, D(Vec), S(Obj), PRc|CRc
| StStk<offset>, ND, S(StkPtr) S(Cell), NF
| CheckType<T>, DRefineS(0), S(Cell), B|PThe first line says that ConvObToVec is an IR op taking one SSATmp input that
must be a subtype of TObj, and returning a subtype of TVec. The last bit of
that line defines the op's "flags" - in this case, it consumes a refcount on
the input and produces one on the output. That is: this IR op takes care of the
inc-ref and dec-ref required by the CastVec bytecode.
For a complete list of flags, look arlier in the file.
The second line says that StStk takes two inputs - a stack pointer and an
arbitrary Hack value (Cell == "mixed") - and doesn't return anything (ND == "no
destination"). That should make sense, as StStk simply writes to memory. StStk
doesn't have any additional flags, but it does have an immediate: the stack
offset, which is a constant offset off of the stack pointer. (Recall that an
immediate is an argument to an instruction that is "immediately" available at
compile time, as opposed to inputs, which are only available at runtime.) The
immediates in ir.specification are just comments; we define the immediates for
each op in code at the end of the file extra-data.h.
If you look for StStk there, you'll see that it takes an immediate of type
IRSPRelOffsetData.
The third line says that CheckType takes one input, an arbitrary Hack value,
and returns an output. The return type of this op isn't fixed; instead, it must
be computed at compile time based on the input, and based on the immediate type
T that a given CheckType is checking for. For example, we might compile a
CheckType op, which takes a Cell input, checks that it's a bool, and
returns a refined SSATmp of type TBool. If the runtime type check fails, then
instead of returning the result, we'd branch (the "B" flag) to a "taken" block.
The JIT type system
A key difference between Hack bytecode and HHVM's IR is that each IR op places
type constraints on its inputs, and produces typed outputs. The "CastVec"
bytecode will take any type of input from the stack and "do what vec cast does"
for that input. That could mean leaving the input unchanged (if it's a vec),
doing a cast (if it's a dict, keyset, or object), or throwing an error (all
other types). By contrast, the ConvObjToVec IR op will only implement vec
cast behavior for object inputs. It's up to our irgen code to only generate
IR ops whose type constraints are satisfied.
In debug builds, after completing irgen for some block of code, we have
assertions to check these type constraints. The only invariants that hold in
HHVM are the ones that we actively check in debug mode!
We've already seen some examples of JIT types in the sources and destinations
of each op in ir.specification. These examples are instances of the C++ class
jit::Type, defined in hphp/runtime/vm/jit/type.h.
A JIT type constraints what a Hack value could be at runtime. Unlike the Hack
type system, the JIT type system is sound, modulo bugs in HHVM: it provides
actual guarantees about runtime values, and without these guarantees, we
wouldn't be able to produce any nontrivial machine code.
Each instance of a JIT type represents a subset of values that match that type.
For example:
- TCell matches "any Hack value" - that is, an int, an array-like, an object, etc.
- TInt matches "any Hack integer" - some int, but we don't know which.
- TInt=5 matches "the integer 5"
That's right - JIT types can be constants! Like most compilers, HHVM does
constant folding: if an IR
instruction has constant inputs, and if it's side effect free, HHVM may compute
its output at compile time. We check if an IR instruction can be const-folded
by checking if all of that instruction's source SSATmps have types of constant
values.
Based on the previous lesson, on refcounting, you may have a guess as to why
we could have constant subtypes of TInt, TStr, TVec, etc., but not of TObj.
There are even some types, like TInitNull, which always represent a constant!
The set of all JIT types forms a mathematical structure called a lattice.
That means that JIT types support two key operations: union and intersection.
The union | of two JIT types A and B is the type of values that could be an A
or a B. The intersection & of A and B is the type of values that must be both
an A and a B. Here are a few examples:
TCell | TInt = TCell // A TCell could be any Hack value, so this union could be, too.
TCell & TInt = TInt // A TCell and an int must be an int.
TStr & TInt = TBottom // No value is an int and a string. TBottom is "the empty set".
TInt | TStr = (Int|Str) // jit::Type can represent this union...
(Int|Str) | TInitNull = (Int|Str|InitNull) // ...and this one.
// jit::Type can represent any union of Hack DataTypes (i.e. "top-level" Hack types)!
TInt=3 | TInt=5 = TInt // We can't represent two constants, so we expand this union.
TInt=3 & TInt=5 = TBottom // No value is ever both the int 3 and the int 5.We use the lattice operations to bound the type of values at compile time:
- Unions represent merged control flow. If we compile an if statement, and
we assign local $x a value of type X in the "then" block and a value of type
Y in the "else" block, then afterwards, $x could be either one. It has type X | Y. - Intersections represent type checks. If some SSATmp has the type X, and
we do a CheckTypeon it, then if the check passes, both type constraints
apply. The CheckType result has type X & Y.
There's a lot more to learn about this type system, but it's best done by
reading code. Luckily, jit::Type is one of the most heavily-tested parts of
HHVM. In addition to exercising this code via the end-to-end tests in thehphp/test directory, we have
a battery of unit tests which double as examples of usage and expected behavior.
Step 1: Optimizing CastVec IR generation
Phew! That was a lot of information. Take five and step away from the computer.
Go for a walk! Get a cup of coffee!
...but before you do, kick off a build of HHVM =)
...
...
...
...
...
...
...
...
...
...
...
...
...
...
...
...
It's time to apply what we've learned to optimize the example from the start of
this lesson - a simple vec cast on a Vector. At a high level, here's what we
have to do:
- We want to improve irgen for CastVec. By convention, we know that the
interpreter's logic for this bytecode is in iopCastVec and that the JIT's is
in emitCastVec. We must edit emitCastVec. - Our optimization can only kick in if we have the right type info for the
input. We must check if the stack's top value is a subtype of
TObj<=HH\Vector, the specialized jit::Type representing objects of class
Vector. - We need to use better - more specialized! - IR ops that will be more
performant for this cast case. We must find and compose existing IR ops,
e.g. one that fetches the vec that backs a Vector.
Let's go through these items together. For item 1, we can simply search in thehphp/runtime directory for "emitCastVec". Here's the code that I found.
At the rev you're working at, the code may be a bit different, but the
high-level structure is probably similar:
void emitCastVec(IRGS& env) {
auto const src = popC(env);
auto const raise = [&](const char* type) {
auto const message =
makeStaticString(folly::sformat("{} to vec conversion", type));
gen(env, ThrowInvalidOperation, cns(env, message));
return cns(env, TBottom);
};
push(
env,
[&] {
if (src->isA(TVec)) return src;
if (src->isA(TArrLike)) return gen(env, ConvArrLikeToVec, src);
if (src->isA(TClsMeth)) return raise("ClsMeth");
if (src->isA(TObj)) return gen(env, ConvObjToVec, src);
if (src->isA(TNull)) return raise("Null");
if (src->isA(TBool)) return raise("Bool");
if (src->isA(TInt)) return raise("Int");
if (src->isA(TDbl)) return raise("Double");
if (src->isA(TStr)) return raise("String");
if (src->isA(TFunc)) return raise("Func");
if (src->isA(TRes)) return raise("Resource");
PUNT(CastVecUnknown);
}()
);
}What's going on here? The C++ lambda inside the "push" call returns an SSATmp
that's the result of doing a cast. TVec, TArrLike, TObj, etc. are all JIT
types; most of them represent values of some DataType, but TArrLike is a
special union type that just means - you guessed it! - TVec | TDict | TKeyset.
In the TVec, TArrLike, and TObj cases, we produce IR to actually do the cast.
In all of the other cases, here, the cast is guaranteed to throw, so we emit a
"terminal" (the flag "T" in ir.specification) op ThrowInvalidOperation. This
op is guaranteed to halt execution, so we can return a dummy SSATmp of type
TBottom, since we know that value is unreachable.
When compiling a bytecode to IR, it's critical to keep track of refcounting.
This bytecode pops one value - the "src" SSATmp - from the stack, and pushes
one value - the return value of the lambda - onto it. Based on the standard
refcounting semantics, we must dec-ref the stack input and inc-ref the
output.But we don't see any DecRef or IncRef IR ops here! That's because the
ConvArrLikeToVec and ConvObjToVec IR ops handle the refcounting for us. We
can confirm that by reading their ir.specification entries. The CRc|PRc flags
on these ops mean that they consume a refcount on their input and produce one
on their output. If we replace these ops with ones that don't handle
refcounting, we'll have to generate refcounting IR ops ourselves.
For item 2, we must check the conditions under which our optimization applies.
Since we're trying to optimize vec casts on Vector objects, we only have to
consider the TObj case, but we can't apply our optimization to all objects. We
must use the JIT type system to check for a more specific type here.
Let's make this change by editing the TObj line alone. We can insert another
C++ lambda to give us some syntactic space to make this logic more complex.
Then, we can use the jit::Type's specialized constructor, jit::Type::SubObj, to
produce a type to compare against here:
if (src->isA(TObj)) return [&]{
auto const TVector = Type::SubObj(c_Vector::classof());
if (src->isA(TVector)) {
// TODO: We can optimize this case!
}
return gen(env, ConvObjToVec, src);
}();We create and immediately invoke a closure here as a way to pack complex
control flow in an expression position. It's the same reason that we create
and invoke the outer closure, the one that produces the output that we push
onto the stack. You could consider this pattern a "cute" "trick", or you
could refactor this code to use named helper methods instead. The choice is
yours!
Make these edits and check that HHVM still compiles. (You may need to include
"hphp/runtime/ext/collections/ext_collections-vector.h" at the top of the file,
too.) On to item 3! We need to find an IR op to extract the vec that backs a
vector. There are a few ways to look for this op:
- We could do a (case-insensitive) search for "collection" or "vector" in the file.
- We could look for ops that take an object as input, by searching for S(Obj).
- We could look for ops that return a vec as output, by searching for D(Vec).
The first approach quickly leads us to:
| LdColVec, D(Vec), S(Obj), NF
Load the vec array backing a collection instance in S0. S0 must be a Vector
or ImmVector, and that specific object type must be known at compile time.That's exactly what we need! However, we now have to generate the appropriate
refcounting IR ops. Unlike ConvObjToVec, LdColVec has no special flags - it
simply does a load. In order to generate any IR ops here, we need to use the
magical templated "gen" helper. This helper takes a variadic inputs and builds
and IR instruction from those inputs. In order, its inputs are:
- The IRGS& environment struct "env".
- The IR op for the new instruction, specified by its name. (It's a C++ enum.)
- (Optional) The "taken" branch, a jit::Block to jump to. Only used for control-flow-y ops (with flags "B" or "T").
- (Optional) The jit::Type for the op. Only used if the op takes a type immediate, like CheckType does.
- (Optional) Any other immediates associated with that op. (See extra-data.h to see if each op takes one.)
- The list of source SSATmps for that op, in order.
To get this optimization working, we must gen an LdColVec, an IncRef of the
result, and a DecRef of the input. LdColVec and IncRef don't need any of the
three optional arguments above. DecRef has an associated immediate, but no
other optional arguments. As a result, this code should work for us:
if (src->isA(TObj)) return [&]{
auto const TVector = Type::SubObj(c_Vector::classof());
if (src->isA(TVector)) {
auto const result = gen(env, LdColVec, src);
gen(env, IncRef, result);
gen(env, DecRef, DecRefData(), src);
return result;
}
return gen(env, ConvObjToVec, src);
}();It's important that we do the IncRef here before the DecRef. Make sure you understand why!
When you've got this code compiling, rerun the TRACE=printir:1 command with the
test file and your new HHVM. You should see that the C++ call in ConvObjToVec
has been replaced with an offset load. Our code generation for this case is
much improved!
Step 2: Moving the optimization to simplify
When writing a compiler optimization, you should always try to make it as
general as possible. If your optimization only applies in certain special
cases, then users of your language may see large performance differences due to
small edits to their code, which is a frustrating experience! Further,
performance measurement can be noisy, and the more cases your optimization
covers, the greater the chance of measuring an unambiguous improvement on real
benchmarks.
Compiler engineers are greedy. They want to optimize as many cases as
possible. Compiler engineers are also lazy. They may give up at the point at
which it becomes hard to extend an optimization further. As a result of these
warring impulses, compilers exhibit stepwise behavior. Ubiquitous, simple
cases are heavily optimized. A variety of common cases are handled somewhat
well. The general case may be slow as molasses.
There are a variety of ways to extend this optimization. We'll look at simpler
extensions in the exercises, but the most important extension here is to take
advantage of late type information.
HHVM includes a variety of optimization passes.
These passes run after irgen is complete. They take an IR unit that implements
some Hack source code, and modify it in place to produce better IR that
implements the same code. These passes can produce tighter type bounds on
values than we originally had at irgen time; for example, if we inline a
function, we may get a better type for its return value, which recursively
results in tighter types for SSATmps computed based on that value.
If we can do an IR optimization without introducing new control flow, we can
take advantage of these late types by moving the optimization to simplify.cpp.
The simplifier is run on every IR op after every optimization pass. If there's
any opportunity to make the optimization, the simplifier will find it. The
simplifier's contract is, uh, simple...it processes a single IR instruction,
and if it's possible to replace it with zero or more IR instructions, it does
so.
When can we simplify an operation down to zero IR instructions? When we can
const-fold it! Take a look at simplifyXorInt for an example. One of the cases
we optimize here is xor-ing an SSATmp with itself. If we do that, we can
return cns(env, 0). Since this result is a constant SSATmp, we don't need to
execute any IR instructions to compute it. The simplifier will remove the
XorInt op and replace its result with the constant.
To move the optimization to the simplifier, we need to:
- Revert the change in the irgen code. The simplify version is strictly
more general. If we leave the irgen change around, then when we examine the
printir, it'll be hard to tell which of our changes kicked in. - Add a simplifyConvObjToVec function to simplify.cpp. We won't be able to
const-fold here, but based on the input's type, we can still replace this IR
instruction with a more optimized sequence of instructions. - Register the IR op in the "X" macro below. Search for "XorInt" in the
file. It should appear in a few places, but one line in particular will just
say "X(XorInt)". This line is a macro invocation that dispatches to the new
simplify function.
Here's what I've got for the simplify code (as a diff: D34433352):
SSATmp* simplifyConvObjToVec(State& env, const IRInstruction* inst) {
auto const src = inst->src(0);
auto const TVector = Type::SubObj(c_Vector::classof());
if (src->isA(TVector)) {
auto const result = gen(env, LdColVec, src);
gen(env, IncRef, result);
gen(env, DecRef, DecRefData(), src);
return result;
}
return nullptr;
}It's basically the same as the irgen version, but by doing it in the right
place, we optimize more cases! Make this change, get it to compile, and verify
that you can see the optimization on the test case. Then kick off diff testing
and benchmarking!
Lesson summary
- HHVM's IR is typed under the sound JIT type system. This type system
constrains which IR operations we can use on a given value, but we can also
use these types as optimization conditions. - We use profiling tools to find "hot" (high-cost) functions that may be good
targets for optimization. - Once we've found a potential optimization target, we create a small test case
and verify that the opportunity still exists at the highest optimization
level. - When we make an optimization, we should apply it to as many cases as
possible. One way to make an optimization more general is to move it to the
simplifier, so it runs whenever we gain type information.
Exercises
Your first exercise is to think about how else we could extend this optimization. Do so before reading on!
...
...
...
...
...
...
...
...
...
...
...
...
...
...
...
...
For all of these extensions, be sure to create a small test case that shows the current, suboptimal code generation first. Then make the optimization and confirm that the test case is improved!
- Modify the optimization to apply to vec-cast on ImmVector as well as vec-cast on Vector.
- Add optimizations for doing a dict cast on a Map, ImmMap, Set, and ImmSet.
- Add optimizations for doing a vec cast on a Map, ImmMap, Set, or ImmSet, and for doing a dict cast on a Vector or ImmVector. (Look for other relevant IR ops, like the ones that convert between different array-likes. Watch out for refcounting!)
Hphp Hack Doc HIPs Contexts And Coeffects (`hphp/hack/doc/HIPs/contexts_and_coeffects.md`)
Feature Name: Contexts and CoEffects
Start Date: July 14, 2020
Status: Candidate
Summary:
A generalized system for the description and enforcement of permissions and restrictions of a context.
This HIP presents feature that overlays a coeffect system into the Hack type system through lightweight context annotations that logically map into a set of capabilities. These capabilities establish both the calling conventions (i.e., which functions/method may call which other functions/methods) as well as the operations permitted within the present context.
“Contexts are like onions.”
Feature motivation:
Several important in-progress language features require us to alter behavior dependent on the executing context. Examples of this include:
- Context Implicit Purpose Policies (CIPP): Inside of a CIPP context, there are stricter rules about how data can be handled in order to ensure their purposes are maintained.
- Purity/Reactivity: In a pure/reactive context, the programmer cannot access global data nor mutate external references in order to achieve enforced determinism.
In order to support execution contexts with different permissions/restrictions, we propose adding Contextual Effects (Coeffects) to the Hack type system. Coeffects are expressive enough to support generalized execution scopes while also lending themselves nicely to syntactic sugar that will make them easy to use and require minimal changes to typechecker internals.
Definitions
In the following document we will use a few terms either not previously defined within the context of Hack or else potentially nonspecific. For the sake of clarity, we will first have a list of definitions for reference throughout the rest of the document.
Capability: a permission or description of a permission. For example, one might consider the ability to do io or access globals as capabilities.
Context: a higher level representation of a set of capabilities. A function may be comprised of one or more contexts which represent the set union of the underlying capabilities.
Example context: for the purpose for the rest of the definitions, a context created solely for the purpose of this document rather than for intended release.
The Pure Context: A simple way of saying the context representing the empty list of capabilities {}.
rand: An example context representing the capabilities required to do random number generation. Refers to the set of capabilities {Rand}.
io: An example context representing the capabilities required to do IO. Refers to the set of capabilities {IO}.
throws<T>: A parameterized example context representing the capabilities required to throw exception type T or any children of T. Refers to the set of capabilities {Throws<T>}. One may consider that the type parameter of this context is covariant.
defaults: The context representing the set of capabilities present in a function prior to the introduction of this feature. Refers to the set of capabilities {Throws<mixed>, IO, Rand}.
Higher order functions: A function accepting, as one of its arguments, a value containing a function-type such as a function pointer or closure.
User Experience - Syntax
Note that all of the below syntax is still very much up for bikeshedding and may not be finalized.
The specification for the syntax of a function including co-effects is as follows, with the list of co-effects itself being optional.
function function_name<list_of_generics>(
parameter_type_hint variable_name,
...
)[list_of_contexts] : return_type_hint where_clauses {
function_body
}Declaring Contexts and Capabilities
This is done natively within the typechecker and the runtime. More information on this is provided under those sections.
Basic Declarations and Closures
A function or method may optionally choose to list one or more contexts:
function no_contexts(): void {...}
function one_context()[C]: void {...}
function many_context()[C1, C2, ..., Cn]: void {...}Additionally, the context list may appear in a function type:
function has_fn_args(
(function (): void) $no_list,
(function ()[io, rand]: void) $list,
(function ()[]: void) $empty_list,
): void {...}As with standard functions, closures may optionally choose to list one or more contexts. Note that the outer function may or may not have its own context list. Lambdas wishing to specify a list of contexts must include a (possibly empty) parenthesized argument list.
function some_function(): void {
$no_list = () ==> {...};
$single = ()[C] ==> {...};
$multiple = ()[C1, C2, ..., Cn] ==> {...};
$with_types = ()[C]: void ==> {...};
// legacy functions work too
$legacy = function()[C]: void {};
// does not parse
$x[C] ==> {}
}Higher Order Functions With Dependent Contexts
One may define a higher order function whose context depends on the dynamic context of one or more passed in function arguments.
function has_dependent_fn_arg(
(function()[_]: void) $f,
)[rand, ctx $f]: void {... $f(); ...}
function has_dependent_fn_args(
(function()[_]: void) $f,
(function()[_]: void) $f2,
)[rand, ctx $f, ctx $f2]: void {... $f(); ... $f2(); ...}One may reference the dependent context of a function argument in later arguments as well as in the return type.
function has_double_dependent_fn_arg(
(function()[_]: void) $f1,
(function()[ctx $f1]: void) $f2,
)[rand, ctx $f1]: void {$f1(); $f2(); }
function has_dependent_return(
(function()[_]: void) $f,
)[rand, ctx $f]: (function()[ctx $f]: void) {
$f();
return $f;
}Attempting to use the dependent context of an argument before it is defined will result in an error about using an undefined variable.
Note that the special _ placeholder context may only be used on function arguments.
// The following are all disallowed
type A = (function()[_]: void);
newtype A = (function()[_]: void);
Class Example {
public (function()[_]: void) $f;
public static (function()[_]: void) $f2;
}
function example(): (function()[_]: void) {}Constants
In addition to standard and type constants, classes may define context constants:
class WithConstant {
const ctx C = [io];
...
}Context constants may be abstract, and possibly have a default. If they are abstract, they may additionally contain one or both of the as and super constraints.
interface WithAbstractConstants<T> {
abstract const ctx C1; // bare abstract
abstract const ctx C2 = [io]; // abstract with default
abstract const ctx C3 as [io, rand]; // abstract with bound
abstract const ctx C4 super [io, rand] = [io]; // abstract with bound and default
// Disallowed: Concrete with bound.
const ctx C5 super [io, rand] = [io];
}Context constants are accessed off of function arguments in a similar manner to function-type arguments. The same restrictions about use-before-define apply.
function type_const(SomeClassWithConstant $t)[$t::C]: void { $t->f(); }Context constants are accessed off this or a specific type directly within the contexts list:
public function access_directly()[this::C1, MySpecificType::C2]: T {...}Context constants may not be referenced off of a dependent/nested type. Said another way, context constants may only have the form $arg::C, not $arg::T::C, etc. It is possible we will relax this restriction in a future version.
interface IHasCtx {
abstract ctx C;
}
interface IHasConst {
abstract const type TC as IHasCtx;
}
// Disallowed: nested type access
function type_const(IHasConst $t)[$t::TC::C]: void {}
abstract class MyClass implements IHasConst {
// Disallowed: also applies to dependent types of this
public function type_const()[this::TC::C]: void{}
}For the sake of simplicity, this must be used in lieu of $this within the context list.
Additional Dependent Contexts Information
Dependent contexts may be accessed off of nullable parameters. If the dynamic value of the parameter is null, then the contexts list will be empty.
function type_const(
?SomeClassWithConstant $t,
?(function()[_]: void) $f,
)[$t::C, ctx $f]: void {
$t?->foo();
if ($f is nonnull) {
$f();
}
}Parameters used for accessing a dependent context may not be reassigned within the function body.
function type_const(SomeClassWithConstant $t)[$t::C]: void {
// disallowed
$t = get_some_other_value();
}
function has_dependent_fn_arg((function()[_]: void) $f)[ctx $f]: void {
// disallowed
$f = get_some_other_value();
}Dependent contexts may not be referenced within the body of a function. This restriction may be relaxed in a future version.
function f(
(function()[_]: void $f,
SomeClassWithConstant $t,
)[rand, ctx $f, $t::C]: void {
(()[ctx $f] ==> 1)(); // Disallowed
(()[$t::C] ==> 1)(); // Disallowed
(()[rand] ==> 1)(); // Allowed, not a dependent context
(()[] ==> 1)(); // Allowed
(() ==> 1)(); // Allowed. Note that this is logically equivalent to [rand, ctx $f, $t::C]
}User Experience - Semantics
Basic Function Declarations
As this feature is fully opt-in, the lack of a context list results in implicity having the defaults context.
I.E. the following to definitions are functionally identical:
function default_context1(): void {...}
function default_context2()[defaults]: void {...}How Contextful Functions Interact
Contexts represent a set of capabilities. A list of contexts represent the set union of their capabilities. In order to invoke a function, one must have access to all capabilities required by the callee. However, the caller may have more capabilities than is required by the callee, in which case simply not all capabilities are "passed" to the callee.
This is perhaps more easily seen via example:
function pure_fun()[]: void { /* has {} capability set */}
function rand_int()[rand]: void {/* has {Rand} capability set */}
function rand_fun()[rand]: void {
pure_fun(); // fine: {} ⊆ {Rand}
rand_int(); // fine: {Rand} ⊆ {Rand}
}
function unannotated_fun(): void {
rand_fun(); // fine: {Rand} ⊆ {IO, Rand, Throws<mixed>} aka the default set
}
function pure_fun2()[]: void {
rand_fun(); // error: {Rand} ⊈ {}
}Above, rand_fun is logically safe, as its ability to do nondeterministic computation shouldn’t prevent it from invoking functions without that ability. However, note that pure_fun2 is unsafe, as it does not have that capability, and therefore must refrain from invoking those that require it.
Now consider the following:
function pp_coinflip()[io, rand]: void {
pretty(conflip());
}
function coinflip()[rand]: bool {
return rand_int() == 0;
}
function pretty(bool $b)[io]: void {
print($b ? "heads" : "tails");
}
function pure_fun()[]: void {
pp_coinflip(); // whoops
}The invocation of pp_coinflip from pure_fun is obviously unsafe, as invoking pure_fun could, in fact, actually result in impure actions. Therefore, functions with the pure context only invoke other functions with the pure context. Note, however, that pp_coinflip is fine to invoke coinflip and pretty.
Subtyping & Hierarchies
Semantically, capabilities work as if they were required parameters
to functions, and are thus contravariant. This means that, for example,
a closure that requires a [rand] or [] (pure) context may be passed
where the expected type is a function that requires [rand, io].
(The converse is disallowed because that would mean giving an
additional capability for randomness out of thin air.)
The errors then fall out by normal subtyping rules by internally
treating permissions as (implicit) arguments of a function/method.
class Parent {
public function maybeRand()[rand]: void {...} // {Rand}
public function maybePure(): void {...} // {Throws<mixed>, IO, Rand}
}
class Mid extends Parent {
public function maybeRand()[rand]: void {...} // {Rand} -> fine {Rand} ⊆ {Rand}
public function maybePure()[io]: void {...} // {IO} -> fine {IO} ⊆ {Throws<mixed>, IO, Rand}
}
class Child extends Mid {
public function maybeRand()[]: void {...} // {} -> fine {} ⊆ {Rand}
public function maybePure()[]: void {...} // {} -> fine {} ⊆ {IO}
}In the above, the contexts on the methods in Parent and Child are required for Mid to typecheck successfully. Note also that maybePure in Parent need not be the pure context, and that maybeRand in Child need not be rand.
Capability subtyping
In reality, there may also exist a subtyping relationship between
capabilities; suppose that a new capability FileInput is defined.
Since reading from a file does not preclude one from reading
a special file such as /dev/random on a UNIX-like system,
the semantic model should conservatively assume that a function
with capability FileInput must also have the Rand capability.
Therefore, FileInput must be a subtype (subcapability) of Rand.
This has an important consequence that falls out by design:
whenever some capability B that is subtype of capability A
is available (in scope), any function (or operation) that
requires A can be called (or performed, respectively).
Interaction with closures
By default, closures require the same capabilities as the context in which they are created. Explicitly annotating the closure can be used to opt-out of this implicit behaviour. This is most useful when requiring the capabilities of the outer scope result in unnecessary restrictions, such as if the closure is returned rather than being invoked within the enclosing scope.
function foo()[io]: void { // scope has {IO}
$callable1 = () ==> {...}; // requires {IO} - By far the most common usage
$callable2 = ()[] ==> {...}; // does not require {IO}, requires {}
$uncallable1 = ()[rand] ==> {...}; // does not require {IO}, requires {Rand}
$uncallable2 = ()[defaults] ==> {...}; // does not require {IO}, requires the default set
}Note that in the previous example, $uncallable1 cannot be called as foo cannot provide the required Rand capability. $callable2 is invocable because it requires strictly fewer capabilities than foo can provide.
Higher-order Functions With Dependent Contexts
Higher-order functions are typically used for generalization purposes, with common examples including standard map and filter functions. For these functions, a common pattern is to generalize over the inputs and/or outputs of their function-typed arguments. It is imperative that the addition of contexts does not remove this generalizability while maintaining simplicity of their definitions.
Consider the following higher-order function declaration and calling functions. In order to maintain generality, safety, and backwards compatibility, the end result needs to be that good_caller and nocontext_caller should typecheck while bad_caller should not. We solve this problem via the use of dependent contexts, defined above.
function callee(
(function()[_]: void) $f,
)[rand, ctx $f]: void {... $f(); ...}
function good_caller()[io, rand]: void {
// pass pure closure
callee(()[] ==> {...}); // callee is {Rand}
// pass {IO} closure
callee(()[io] ==> echo "output"); // callee is {Rand, IO}
// pass {IO, Rand} closure
callee(() ==> echo "output"); // callee is {Rand, IO}
callee(() ==> {...}); // callee is {Rand, IO}
}
function bad_caller()[]: void {
// pass {} closure but tries to do IO
callee(()[] ==> echo "output"); // // C is {} -> callee is {Rand}
// pass {} closure
callee(() ==> {...}); // C is {} -> callee is {Rand}
}
function nocontext_caller(): void {
// this closure requires the default context
callee(() ==> {...}); // callee is {Rand, Throws<mixed>, IO}
}Note that, logically, this suggests and requires that all other statements within callee require only the Rand capability, as the actual C passed cannot be depended upon to be any specific capability (and can in fact be the empty set of capabilities).
A potentially more compelling example is the Vec\map function in the Hack Standard Library.
function map<Tv1, Tv2>(
Traversable<Tv1> $traversable,
(function(Tv1)[_]: Tv2) $value_func,
)[ctx $value_func]: vec<Tv2> { ... }Partially Contextful Hierarchies aka Context Constants
Background
It is not uncommon to want your API to accept an object implementing an interface and then invoke a method appearing on that interface. That is fine in the common case, wherein the context of that function within the hierarchy is invariant. However, it is possible for a hierarchy to exist for which it is difficult or impossible to guarantee that all implementations of a method have the same context. There are a good number of these situations within the Facebook codebase, but the easiest example is the Traversable hierarchy.
The following is an oversimplification of the hierarchy and methodology for how Traversables work in Hack. Do not consider this an actual reference to their inner workings. Consider a Traversable interface that defines a next function which gives you the next item and an isDone function that tells you if there are more elements.
interface Traversable<T> {
public function next()[???]: T; // what do we put here?
public function isDone()[]: bool; // always pure
}The most common children of Traversable are the builtin Containers, with children like vec and Map. However, non-builtin objects are allowed to extend Traversable as well, creating arbitrarily traversable objects.
interfact Container<T> implements Traversable<T> {
public function next()[]: T; // {}
}
final class CreateTenNumbers implements Traversable<int> {
private int $nums = 0;
private bool $done = false;
public function isDone()[]: bool { return $this->done; } // {}
public function next()[rand]: int { // {Rand}
invariant(!$this->done, 'off the end');
if ($this->nums++ === 10) { $this->done = true; }
return rand_int();
}
}Now consider the following function:
function sum(Traversable<int> $nums)[]: int { // Has {}!!!
$sum = 0;
while(!$nums->isDone()) {
// if $nums is CreateTenNumbers, this is unsafe!
$sum += $nums->next(); // hmmmmm
}
return $sum;
}This code should not typecheck! The sum function has no capabilities, but what are the capability requirements of the call to next?
Solution
The solution to this problem is the capability constants described above; the idea simply being that the interface has an abstract capability list, usable on methods of the interface, and concretized by children. In our case, we would use such a capability constant to describe the next function:
interface Traversable<T> {
abstract const ctx C;
public function next()[this::C]: T;
public function isDone()[]: T;
}
interface Container<T> implements Traversable<T> {
const ctx C = [];
}
final class CreateTenNumbers implements Traversable<int> {
...
const ctx C = [rand];
public function next()[rand]: int { ... }
...
}
function sum(Traversable<int> $nums)[$nums::C]: int { ... }In fact, the Vec\map function above would likely actually look something like this:
function map<Tv1, Tv2>(
Traversable<Tv1> $traversable,
(function(Tv1)[_]: Tv2) $value_func,
)[$traversable::C, ctx value_func]: vec<Tv2> { ... }As with normal type constants, one cannot override a concrete capability constant.
Context Constant Defaults
As with normal type constants, we don’t want to force all children to specify the constant. Thus we expose abstract context constants with defaults.
abstract const ctx C = [defaults];Here, the first non-abstract class that doesn’t define a concrete context for C gets [defaults] synthesized for it.
Local operations
Examples of coeffect-enforced operations include printing,
static property or superglobal access, as well as throwing exceptions.
For output such as from invocation of the echo built-in, the IO
capability must be locally available in the function/method body.
function io_good()[io]: void {
echo "good"; // ok: {IO} ⊆ {IO}
}
function io_bad()[]: void {
echo "bad"; // error: {} ⊈ {IO}
}Parameterized Contexts / Capabilities
Thus far, we’ve covered examples of relatively simple contexts, effectively representing a binary option. However, contexts must also be usable for situations requiring more than that binary choice. Consider, now in more detail, the throw context defined above. Rather than describing that a function can throw, this would describe which classes of exceptions a function can throw. In that scenario, the context would require a parameter representing the exception class: throws<-T as Exception>.
One might note, however, that it would be reasonable for a function to throw multiple kinds of exceptions, often without a unified hierarchy. While it is possible to use an additional interface to unify those exceptions, that would quickly result in a combinatorial explosion. Instead, the result would look like this:
function throws_foo_exception()[throws<FooException>]: void { // {Throws<FooException>}
throw new FooException();
}
function throws_bar_exception()[throws<BarException>]: void { // {Throws<BarException>}
throw new BarException();
}
function throws_foo_or_bar_exception(bool $cond)[
throws<FooException>, throws<BarException> // {Throws<FooException>, Throws<BarException>}
]: void {
if ($cond) {
throws_foo_exception();
} else {
throws_bar_exception();
}
}The above would indicate that throws_foo_or_bar_exception may throw any of the listed exception classes.
This also applies to to the introduction of additional instances of a parameterized context due to dependent contexts:
function throws(
(function()[_]: void) $f,
)[throws<FooException>, ctx $f]: void {...}In the above, if ctx $f is throws then throws($f) would be {Throws<FooException>, Throws}.
IDE experience:
The IDE experience for this feature will be similar to that of standard types. Autocomplete, syntax highlighting, etc will all be available.
The error messages will be customizable for each context such that the errors make it clear
- What’s missing in order to typecheck function calls successfully
- What illegal operation is taking place within the body of a contextful function.
For example, attempting to echo within a function having the pure context would simply indicate that doing IO is not permissable due to that context.
Implementation details:
Typechecker
The proposed system is modeled as implicit parameters in the
typechecker. These parameters do not exist at runtime, and the
locals that carry them are not denotable by users ($# prefix).
The parameters are automatically looked up and checked during type
inference of call expressions via the $#capability local.
Coeffect-enforced local operations (such as I/O and throwing)
look up and check if the $#local_capability has the
appropriate type. Multiple capabilities are encoded using
an intersection type, i.e., a capability set {C1, C2, C3}
would be represented as (C1 & C2 & C3), assuming an
oversimplification (for now) that there exists a one-to-one mapping
between contexts and same-named types. In either case,
the available capability needs to be a subtype of the required
one in order for type-checking to succeed (note that a set is a
subtype of any of its supersets). This way, we reuse existing
typing infrastructure, and get subtyping for free.
namespace HH\Capabilities {
interface A {}
interface B extends A {}
interface A2 {}
}
function f(/* A $#capability */)[A]: void {
f(/* $#capability */); // ok
g(/* $#capability */); // bad, A </: B
} // available^ ^required
function g(/* B $#capability */)[B]: void {
g(/* $#capability */); // ok
f(/* $#capability */); // ok (B <: A)
} // available^ ^required
function h(/* (A & A2) $#capability */)[A, A2]: void {
g(/* $#capability */); // bad (A & A2) </: B)
f(/* $#capability */); // ok (A & A2) <: A)
} // available^^^^^^ ^requiredSo, a function with a mixed coeffect has none of the capabilities we define, and can be called by any function. By comparison, a function with a nothing coeffect has every capability, but can only be called by other functions whose coeffect is nothing. In practice, we won’t use nothing, but rather an intersection type that covers a default set of capabilities, namely defaults.
Mapping of contexts to capabilities
The intended place to define new contexts and capabilities is an.hhi file under hphp/hack/src/hhi/coeffect. In the same directory,
there is also a GraphViz visualization that concisely describes the
relationship between contexts and capabilities system in a way
that may be more understandable to an average Hack developer.
The kind of coeffects coincides with the kind of types. The syntactic
piece [C1, ..., Cn] is interpreted during naming as follows. Each
annotation Ck must either be:
a fully namespace-qualified type using the preferred CamelCase naming,
e.g.,\HH\Capabilities\Rand;or an unqualified type representing a context using a snake case name,
e.g.,rand, which is an alias to one or more of the above
(multiple types are intersected on the right-hand side)
The former would be sufficient and constitutes a sound (co)effect system;
however, we encourage uniformly using the latter instead because
it provides a general mechanism that facilitates top-down migrations
(more details on that below).
In case a context requires and provides multiple capabilities, there
are two choices:
- declare a (sealed interface) supertype of the desired capabilities; or
- declare an alias and use intersection types (preferred).
Declaring an alias to an intersection of capabilities (or other
aliases) is strongly preferred as it allow the requirements to be
constructible in multiple ways; e.g., if a context composite_context
maps to capabilities (and contexts) Cap1 (context1) and Cap2 (context2), then the following code would still work fine as expected,
function callee()[composite_context]: void {}
function caller()[context1, context2]: void {
callee(); // ok (composite_context = context1 & context2)
}unlike the former hierarchy-based approach
(CompositeCap extends CompositeCap1, CompositeCap2)
that would fail with the following message:
This call is not allowed because its coeffects are incompatible with the context (Typing[4390])
... context of this function body provides the capability set
{Capability1, Capability2}
... But the function being called requires the capability
CompositeCapabilityEnforcing local operations
To enforce local operations (such as throwing exceptions),
the typechecker keeps track of a fake local variable$#local_capability. At the beginning of f's body, it has type:
\HH\Contexts\context1 & ... & \HH\Contexts\contextN
where context1 through contextN are the listed on f:
function f(/*params*/)[context1, ..., contextN]: ReturnTypeSince each context contextI maps to some underlying set of
capabilities, the type of $#local_capability simplifies to:
Cap1 & ... & CapK
This kind of coeffect(s) is tracked orthogonally to the one used to
enforce calling conventions and they are always sound; no context
can unsafely conjure a capability to perform a local operation.
Enforcing calling conventions
To establish calling conventions in the typechecker,
user-denoted contexts are mapped to two sets of capabilities:
- C: intersection of types define by
\HH\Contexts\contextI
(exactly as described in the previous subsection) - Cunsafe: intersection of types defined by
\HH\Contexts\Unsafe\contextI
(analogous to the above modulo the namespace resolution)
Then the $#capability local is typed as (C & Cunsafe);
intuitively, this means that the set of available capabilities
for performing a function/method call is the set union of
the safe and unsafe counterparts of each context, where mixed
corresponds to an empty set (i.e., it is no-op). Notably,
this means that usage of a context is always sound if the
underlying type \HH\Contexts\Unsafe\context = mixed.
On the contrary, \HH\Contexts\Unsafe\context = (C1 & C2),
for example, would mean that the usage of the context is unsound
as it unsafely conjures capabilities C1 and C2,
thereby potentially allowing calls into contexts that require
capabilities C1 and C2 that would otherwise not be available.
The errors then fall out by normal subtyping rules by internally
treating $#capability as an implicit function/method argument
that has type: C & Cunsafe.
A comprehensive example
// defined in an .hhi
namespace \HH\Contexts {
type io = IO;
namespace Unsafe { type safe_context = mixed; } // == {}
type unsafe_context = (Rand & Throws<Exception>);
namespace Unsafe { type unsafe_context = IO; }
}
// user code (WWW)
function do_io()[io]: void {
// $#local_capability has type: IO
// $#capability has type: (mixed & IO) = IO
echo(/* $#local_capability */) "good"; // ok (IO <: IO)
}
function cannot_make_local_io_but_can_call_into_io_ctx()[
unsafe_context
]: void {
// $#local_capability has type: Rand & Throws<Exception>
// $#capability has type: IO & (Rand & Throws<Exception>)
if (coinflip(/* $#capability */)) { // <: Rand (ok)
echo(/* $#local_capability */) "bad"; // </: IO
} else {
do_io(/* capability); // ok
} // ^ (Rand & Throws<Exception>) & IO <: IO
}Capturing of capabilities/contexts
When a context list is omitted from a lambda, the type-checker
does not need redefine the two coeffects mentioned above
(for calling and for performing local operations); instead
it exploits capturing of $#capability and $#local_capability
from the enclosing scope (since they are local variables).
This enables memory and CPU savings during type-checking.
Observe that there is no semantic difference between inheriting
capabilities on lambda vs capturing (some of) them from the
enclosing function/method, e.g.:
function pp_coinflip()[io, rand]: void {
$coinflip = () ==> { // capture `rand` */
// VS. ()[io, rand] ==>
return rand_int() == 0;
};
...
}Such capturing is disallowed when context list is present
by merely overwriting the locals with the types resolved
during the context resolution into capabilities. Partial
capturing (i.e., capturing some capabilities but explicitly
requiring others through the context list) is anyway
discouraged for reasons explained in the vision document:
function with_rand_and_io()[rand, io]: void {
// doesn't make much sense (has benefits of neither)
$bad = ()[rand] ==> { /* capture `io` */ ... };
}HHVM
Runtime Semantics of Co-effects
This section will discuss the user observable behavior of co-effects in the runtime. Note that this section does not assume the code type checks via the Hack typechecker as Hack is not a fully static and sound language as well as the typechecker can be bypassed via HH_FIXME and other means. That being said, this section does assume that the code is syntactically correct. For syntactically incorrect programs, the parser will produce an error that will invalidate the entire file in which the syntactical incorrectness is present.
Co-effects will be enforced natively as part of the calling convention; however, the enforcement will be opt-in by the author of the co-effect. This means that each co-effect will always be enforced or never enforced. We will not allow Hack developers to opt out of enforcement; however, we will selectively provide local and shallow enforcement for migratory purposes as well as a global mode where we raise warnings instead of exception. Using the local, shallow or warning level enforcement will not allow you to use features that are enabled by the enforcement but it will be a stepping stone for full enforcement (See migration section for more information).
We will separate co-effects into two buckets from the runtime’s perspective: 1) Erased co-effects, 2) Enforced co-effects.
The erased co-effects represents co-effects where the enforcement is not needed for correctness in runtime nor gives any benefit to the runtime. Hence these co-effects do not need to be enforced in the runtime. These will be dropped from runtime after their syntactical correctness is ensured
The enforced co-effects represents co-effects where the enforcement is required in order to establish correctness and power potential features such as reactive cache and implicit contexts respectively. From here onwards, we are going to assume the co-effects discussed will all be enforced co-effects.
Runtime will have a native knowledge of each co-effect. We will not allow co-effects to be defined in user-land code. All possible list of co-effects will be defined in the runtime via the RuntimeOptions. In addition to their definition, the enforcement level(which includes whether we raise a warning, throw an exception or nothing upon enforcement failures) will also be configurable via the RuntimeOptions to enable safe migration. An important aspect to note here is that certain co-effects that need deep support from runtime such as io and pure will need to be implemented in the runtime (i.e. runtime is aware of them without the RuntimeOptions definition) and RuntimeOption definition of it only denotes the enforcement level of these co-effects.
These restrictions are in place due to performance and correctness requirements of the implementation. Correctness requirement is presence and absence of some of the enforced co-effects enable usage of various features discussed above and in order to grant access to these features, runtime needs to natively know these co-effects. Performance requirement is that the runtime will need to implement some sort of validation and the number of co-effects present will heavily influence the cost of this check. Specific runtime strategies for this check is discussed in implementation details section.
The runtime will enforce the co-effects as a part of the calling convention. The exact order of these checks is still subject to change as most of these checks do not depend on each other for correctness but for those that do, the runtime will create the best topological sorting of the checks. The list of checks that happen as a part of calling convention are the following in their current order:
- Function name resolution
- Visibility checks
- Inout arity and parity checks
- Forbidden dynamic-call checks
- Reified generic arity/parity checks
- Function parameter arity checks
- Co-effect enforcement
- Parameter type enforcement
This means that co-effect enforcement errors will be triggered prior to parameter type errors and other similar errors that trigger the error handler. Co-effect enforcement error will trigger a BadMethodCallException that can be caught in the userland to recover.
If preferable, one may think of the runtime tracking of coeffects as an implicit parameter containing a bitset of active capabilities that is passed to and from all function invocations. At the call boundary, a special check is made to ensure that the parameter as requested is sufficiently provided by the caller. Following that check, however, the passed in implicit parameter is not simply forwarded, but logically replaced by one exactly matching the requirements of the called function. This is similar to "dropping" the unrequired coeffects from the passed in implicit parameter, such that they may not be used inside the callee even if provided by the caller.
Why enforce in HHVM at all?
One may ask why it is necessary to enforce these guarantees at runtime. The language as a whole is generally unsound, and we're working to progress that forward. Why not just add this to the list of pieces that will get iteratively fixed with the rest of the language? Further, what terrible thing actually happens if the rules aren't enforced dynamically?
The answer to the first question is relatively simple. The planned system for a sound dynamic type requires that types implementing it make some guarantees about their properties, methods, etc, such that writing into one via a dynamic type won't result in unsoundness. If all functions have unenforced capabilities, then they would all necessarily preempt a type from implementing dynamic.
There are multiple planned contexts for whom the goal is to make strong guarantees about chain of trust. Without dynamic enforcement, chain of trust is broken and those guarantees aren't successful. This would be tantamount to a constant recurring SEV.
Rules received by HHVM and how they get translated from the syntax
As a part of bytecode emission, HHVM will receive a list of rules for how to compute the enforced capability set for each function declaration (including closure declarations). The final capability set for an invocation of that function is the set union of the results of computing the rules.
The rules are as follows:
- STATIC<set of zero or more capabilities>: The set of statically known capabilities.
- FUN_ARG<arg num>: Argument
is a function-type argument. Read the list of capabilities for the passed in function. Read the empty set if the value is null. Fatal if the argument is not null, a function pointer, a closure, or a PHP-style reference to a function. - CC_ARG<arg num, context constant name>: Argument
is an object that defines a concrete context constant named . Read the contents of that context constant. Read empty set if the value is null. Fatal if the context constant doesn’t exist, is abstract, or if the argument is not null or an object. Arrays are special-cased to have any abstract context constants defined in their class hierarchy. - CC_THIS<context constant name>:
thisdefines a concrete context constant named. Read the contents of that context constant. Fatal if the context constant doesn’t exist or is abstract.
Given a list of coeffects [concrete1, ... concreten, this::C1, ... this::Cn, ctx $arga, ... ctx $argz, $arga::C1, ... $argz::Cn]
- concretek -> STATIC<{concretek}>
- ctx $argk -> if $argk is the name of argument i -> FUN_ARG<*i*>. If no arguments are named $argk, raise an error.
- $argk::Cl -> if $argk is the name of argument i -> CC_ARG<i, Cl>. If no arguments are named $argk, raise an error.
- this::Ck -> CC_THIS<Ck>
Following generation, the STATIC rules are set-unioned into a single rule, and rules are otherwise deduplicated syntactically.
Some examples are as follows along with the emitted list of rules for that declaration.
function f(mixed $x)[io, rand] {}
Rules: [STATIC<{io, rand}>]
function f((function()[_]: void) $x)[io, ctx $x] {}
Rules: [STATIC<{io}>, FUN_ARG<0>]
function f(
(function()[_]: void) $x1,
(function()[_]: void) $x2,
)[io, ctx $x1, ctx $x2] {}
Rules: [STATIC<{io}>, FUN_ARG<0>, FUN_ARG<1>]
function f(Something $x)[io, $x::C] {}
Rules: [STATIC<{io}>, CC_ARG<0, C>]
function f(Something $x1, Something $x2)[$x1::C, $x2::C] {}
Rules: [STATIC<{}>, CC_ARG<0, C>, CC_ARG<1, C>]
public function f()[this::C, rand] {}
Rules: [STATIC<{rand}>, CC_THIS<C>]
public function f(Something $x1)[$x1::C, this::C] {}
Rules: [STATIC<{rand}>, CC_ARG<0, C>, CC_THIS<C>]
public function f(
Something $x1,
(function()[_]: void) $x2,
)[$x1::C, ctx $x2, this::C, IO] {}
Rules: [STATIC<{io}>, CC_ARG<0, C>, FUN_ARG<1>, CC_THIS<C>]What HHVM does with the given rules
HHVM receives a list of rules from the bytecode emitter and processes these rules to create a set of capabilities that will be used to enforce the capabilities of the function call. Note that HHVM will have a list of capabilities but not a mapping between capabilities and the generics that Hack sees which means that the runtime will not not have access to the original forms of the capabilities, generics and context types. This means that the capabilities cannot be reified, used in is/as expressions or accessed via reflection.
HHVM will convert each rule into a set of capabilities and union the sets generated by each rule to finalize the set of capabilities for the function. When HHVM is unable to convert a rule into a set of capabilities, a BadCapabilityException will be raised.
Next, we will discuss how each rule gets converted to a set of capabilities and we will follow it with how HHVM compares the rules for a function call.
For all the following rules, runtime will enforce that
- STATIC<set of zero or more capabilities>
This rule is the most straight forward of the rules. Runtime will iterate over the capabilities and add them to the result set. If any of the capabilities listed on this list is not natively known to the runtime, runtime will raise an exception.
- FUN_ARG<arg num>
Runtime will access the closure object, or the function pointer after loading it, associated with the function in argument slot
- CC_ARG<arg num, context constant name>
Runtime will fetch the argument slot
- CC_THIS<context constant name>
Runtime will access the runtime class of the current function and return the set of capabilities named BadCapabilityException will be raised.
Once set of capabilities are extracted from each rule, the runtime will union the capabilities and generate a final list of capabilities for the function. Note that this list of capabilities contains unique capabilities that are each known to the runtime. At this point, no capability is polymorphic or based off of some other context. We will call this set of capabilities the ambient set of capabilities of the function. As a result of this, polymorphic co-effects cannot be be referenced inside the body of the function, as they have already been resolved at that point.
Due to runtime limitations, specifically, runtime not being able to distinguish between each polymorphic co-effect since runtime operates over the entire set of co-effects, polymorphic co-effects cannot be be referenced inside the body of the function.
Now we will discuss how the runtime enforces a function call based on these set of capabilities. Note that from here on the runtime is operating over the ambient set of capabilities, we will no more need to discuss polymorphism or any other co-effect capabilities.
When a function f calls a function g, the runtime will fetch the ambient set of capabilities for each function. In order to enforce the correctness of capabilities, the caller environment must be able to satisfy all the capabilities of the callee. More concretely, in order for the function call from f to g to happen, the ambient set of capabilities of f must be a superset of that of g. If this is true, the function call will happen with the enforcement guarantee. Otherwise, the runtime will raise an exception, raise a warning or silently ignore based on the enforcement level specified by the compiler configuration.
Due to runtime limitations, specifically, runtime not being able to distinguish between each polymorphic co-effect since runtime operates over the entire set of co-effects, polymorphic co-effects cannot be be referenced inside the body of the function.
End-to-end Example
In this section, we will walk through a real life example end-to-end to illustrate each component. Note that the following example has two function calls to map_with_logging, one good and one bad. We will discuss how and why the good one passes the enforcement and similarly how and why the bad one fails.
function map_with_logging<Tv1, Tv2,>(
Traversable<Tv1> $traversable,
(function(Tv1)[_]: Tv2) $value_func,
)[io, $traversable::C, ctx $value_func]: vec<Tv2> { ... }
function caller_good(vec<int> $v)[rand, io]: void {
map_with_logging($v, ($i)[rand] ==> generate_rand_int());
}
function caller_bad(vec<int> $v)[rand]: void {
map_with_logging($v, ($i)[rand] ==> generate_rand_int());
}First, the bytecode generator will generate the following rules for each function.
Rules for map_with_logging: [Static<{io}>, CC_ARG<0, C>, FUN_ARG<1>]
Rules for caller_good: [Static<{rand, io}>]
Rules for caller_bad: [Static<{rand}>]Secondly, the runtime will extract the ambient set of capabilities of each function.
caller_good: [Static<{rand, io}>] => {rand, io}
caller_bad: [Static<{rand}>] => {rand}Now, let’s inspect map_with_logging for each call.
map_with_logging called by caller_good: [Static<{io}>, CC_ARG<0, C>, FUN_ARG<1>]
Static<{io}> => {io}
CC_ARG<0, C> => {}
FUN_ARG<1> => {rand}
Ambient: {io} | {} | {rand} = {rand, io}map_with_logging called by caller_bad: [Static<{io}>, CC_ARG<0, C>, FUN_ARG<1>]
Static<{io}> => {io}
CC_ARG<0, C> => {}
FUN_ARG<1> => {rand}
Ambient: {io} | {} | {rand} = {rand, io}Notice in both examples the ambient set of capabilities of map_with_logging resulted in {rand, io}. This is due to both functions passing the lambda argument with same capabilities.
Lastly, we will execute the enforcement section.
Ambient set of capabilities of caller_good: {rand, io}
Ambient set of capabilities of map_with_logging called by caller_good: {rand, io}
{rand, io} is a superset of {rand, io}. Function call succeeds.
Ambient set of capabilities of caller_good: {rand}
Ambient set of capabilities of map_with_logging called by caller_bad: {rand, io}
{rand} is NOT a superset of {rand, io}. Function call fails.Eager versus Lazy Enforcement of Conditional Co-effects
The above implementation discusses the eager enforcement of conditional co-effects. Another way to enforce co-effects would be to lazily enforce them. This type of enforcement changes the user observe-able effects of the enforcement since with eager enforcement note that we enforce the lambda on the above example when the lambda is passed as an argument, whereas; with lazy enforcement the enforcement would be done when the lambda is used. This also means that with lazy enforcement more programs would be valid as if the lambda is not used, the enforcement would never be done. Lazy enforcement would allow us to enforce more programs that would otherwise be disallowed with eager enforcement such as passing a vector of co-effectful lambdas. With eager enforcement, since the runtime currently does not enforce inner type of containers, we would disallow such programs from being syntactically correct; however, with lazy enforcement since we’d be enforcing them when the lambda is used, we could allow them.
It is worth re-iterating that lazy enforcement would potentially allow other types of illegal programs to execute:
function callee((function(): void)[_] $f, dynamic $d)[ctx $f] {
// callee inherits only the co-effects from $f (io in this case)
// $d is a dynamic function pointer to a function that requires the rand co-effect
// Because of lazy enforcement we simply forward all co-effects from
// caller() to callee() and work them out later. In this case, we erroneously
// pick up the "rand" co-effect which would allow the invocation of
// $d to succeed incorrectly.
$d();
}
function caller()[io, rand] {
callee(() [io] ==> print(1), random_int<>);
}We have decided the employ the eager enforcement since we believe that it will be more performant for the runtime and less complex since after each function call, we know that everything is enforced and we do not need to keep track of what needs to be enforced.
Since eager enforcement is stricter than lazy, it will be possible to switch to lazy enforcement if needed in the future and we could employ other strategies to get the benefits of lazy enforcement as well by introducing some sort of runtime tagging of containers.
Trade-offs and Restrictions
Historically, adding enforcement to the runtime (parameter and return type enforcement, upper bound generic enforcement, property type enforcement, etc) has often come at a cost as the associated checks are not always free. Ideally the information we gain from this enforcement improves the efficiency of generated code by allowing the JIT to specialize for the enforced properties. This benefit will in many cases offset the cost of the enforcement by allowing the JIT to specialize in places where without enforcement the search space would be too large. In this case, however, the runtime cannot take advantage of the co-effect enforcement to improve code emission. Later, we will discussed some strategies to mitigate possible runtime cost that of this enforcement. Before that we will discuss trade-offs and requirements in order to enforce co-effects with minimal runtime cost.
- Emit significantly more code for these functions: Allowing any arbitrary function to have these polymorphic co-effects would lead to code bloat and instruction cache problems. Our assumption is that polymorphic co-effects will only be common for library functions, and since they are extremely hot and mostly inlined, this would be a good tradeoff.
- Have the runtime aware of each possible co-effect: This means that each co-effect must be defined in the runtime.
- Have a hard limit on how many co-effects there can be: A bounded set of co-effects may be efficiently enforced in O(1) time, while an unbounded set of co-effects will require O(n) checks on each call. The chosen bound will be influenced by call ABI and implementation specific constraints within the runtime.
- Build tracking of more types than we currently do: Currently HHVM does not do any sort of tracking of types for lambdas and type constants. In order to efficiently enforce the above requirements we would need to build such tracking in HHBBC as well as the JIT so that the aforementioned specialized translations can be done. This is, in general, a good direction for the runtime; however, it is also massive amounts of work.
- Future co-effects which may influence runtime behavior and have additional correctness requirements: An example to this would be, without going into too much detail, banning memoization of functions that use polymorphic co-effects and co-effect type constants. For co-effects such as cipp, we will be depending on the enforcement to assure correctness for implicit contexts. Since for memoized functions we need to know at JIT time whether to memoize the context or not, and we cannot specialize on this for correctness reasons. Any function that must be memoized along with the context must be annotated with non-polymorphic coeffect list, such as [cipp] and have the __Memoize attribute. Runtime will not need to determine dynamically whether to capture the context during memoization.
- We will disallow partially forwarding polymorphic co-effects: This is discussed with an example above.
Motivating usecases
Purity
Pure code is not able to do very much, but most standard library functions fit neatly into the model. The most important aspect of an example for this is that both pure and impure code can safely and cleanly utilize the pure framework code.
For hack, as above, the pure context is simply one with the empty context list.
function map<Tv1, Tv2>(
Traversable<Tv1> $traversable,
(function(Tv1)[_]: Tv2) $value_func,
)[$traversable::C, ctx value_func]: vec<Tv2> { ... }
function double_it(vec<int> $v)[]: vec<int> { // {}
return map($v, $i ==> $i*2); // C -> {}, Ce -> {}, overall -> {}
}
// impure code -> not an error
function double_and_print_it(vec<int> $v)[io]: vec<int> {
return map($v, $i ==> {
$doubled = $i*2;
echo "$doubled\n";
return $doubled;
});
}
// ERROR (both with and without [io] on lambda)
function whoops(vec<int> $v)[]: vec<int> {
return map($v, ($i)[io] ==> {
$doubled = $i*2;
echo "$doubled\n";
return $doubled;
});
}CIPP
For CIPP, the most important capability is having access to the runtime Purpose Policy. This is a capability that needs to be added to the context by setting the implicit. Most likely, this will be done using a magic builtin function:
function with_implicit<TIn, TOut>(
TIn $implicit,
(function()[implicit<TIn>]: TOut) $f
): TOut {
..
}Here, the implicit<T> capability gives code the ability to access a value of type T at runtime. The cipp<T> capability is an alias signaling that code should have access to an implicit T and also must be analyzed for data leaks. The API for entering into a CIPP context might look like this:
function eval<Tcipp, T>(
Tcipp $purpose_policy,
(function()[cipp<Tcipp>]: T) $f,
): Wrapped<Tcipp, T> {
...
}Developers would then do something approximating this:
$wrapped_result = Cipp\eval(Policy::MY_POLICY, () ==> {
// Do My Policy things
...
});Design rationale and alternatives:
The vision document on tracking (co)effects
motivates our decision to use a capability-based coeffect system
as opposed to alternatives documented there.
Why this needs to be a language feature
The major question to consider in this section is whether the feature is truly necessary or whether it is possible to emulate this within the language presently. It is generally possible to emulate the cross-function interaction via exposing the hierarchies of permissions in user-space, and then mimicking the behavior with explicit parameters. However, the user experience of that would be absolutely horrendous, especially consider the planned usage of this in such things as the Hack Standard Library. Further, implementing the dependent contexts behavior in user-space, if even possible, would be very complicated and likely extremely confusing.
However, even if one were determined to implement the cross-function behavior of contexts in user-space, there is no way to replicate the special typing rules and effects on function bodies that this proposal provides. Consider the pure context - logically, this requires banning the usage of the echo statement within the body of that function. This is only implementable via a lint rule, which will never be 100% effective, resulting in any given context being transitively untrustable.
Drawbacks
The current design involves checking an extra argument at every function invocation in the codebase. Because the types in question are big intersections, this has the potential to considerably slow down overall typechecking.
Initially, the default context will have 2-3 capabilities (permission for external mutable references, global state, and to call reactive/pure code). Reactive code would have 1 capability and pure would have 0. Multiple capabilities are currently represented via an intersection type in the typechecker, so we need only a single subtyping check for each function/method call, albeit with a large constant factor as subtyping type intersections (and unions) could be expensive.
Options to mitigate this (within the typechecker, not shown to users):
- Special-case the most common cases to avoid full intersection type subtyping/simplification, e.g. if both caller and callee is unannotated, both are fully pure/CIPP, etc. This is probably simplest optimization to try and introduces a fast path and makes the slow path a tiny bit slower.
- Ad-hoc replace intersections of common combinations of capabilities with a single interface type, so that the check is cheap. This wouldn't be too conservative (incomplete but sound) if we can prove that 2 or more capabilities in the intersection of interest cannot be introduced independently (i.e., they always appear in tandem). See subsection "Encoding multiple capabilities" in the Vision doc
Prior art:
See this vision document, section Prior art, for existing systems, which also deal with the challenge of minimizing the burden on users via a “lightweight” syntax.
Effects in Haskell
Haskell is one of the few programming languages that already has an effect system. Although effects in Haskell are monadic rather than capability-based, we can still learn about from how they are used. Haskell functions are pure by default and Haskell has many of the same effects that we have discussed in this document including failure (Either), implicit arguments (Reader), mutation (State), nondeterminism (Random), and IO. Most monads are single purpose, but the IO monad can do nearly everything. IO in Haskell is similar to the default set of capabilities in Hack.
Although Haskell has monad transformers which allows users to combine effects, the common usage pattern is to use a single Monad if you need a single effect, or IO if you need many effects. It is fair to assume that the same will be true in Hack; the best practice should be to use default rather than specifying a long list of individual effects.
Unresolved questions:
Tracking Dynamic calls
A major issue for this proposal, as well as hack generally, is the existence of dynamic invocations. This is worse for contexts, as a dynamic invocation down the stack breaks the transitive guarantees up the stack. A pure function might actually result in IO due to some dynamic call far down the stack. This is not a problem for contexts whose calling conventions HHVM will enforce, but that will almost certainly not be universally the case.
Somewhat ironically, the solution for this is an additional context: can_dyn_call. can_dyn_call gives the capability to invoke functions on receivers that are not known to hack to be static function types. Common examples include HH\dynamic_fun and invoking off of mixed via a HH_FIXME.
function call_dynamically_bad(string $funname, mixed $fun): void {
// HH_FIXME[4009]
$fun('foobar'); // this would be unfixmeable
HH\dynamic_fun($funname)('foobar');
}
function call_dynamically_safe(string $funname, mixed $fun)[can_dyn_call]: void {
// HH_FIXME[4009] - still needs to be fixmed
$fun('foobar'); // but no additional unfixmeable error
HH\dynamic_fun($funname)('foobar');
}This would extend to other types of dynamic invocations as well, such as invoking off a dynamic type, TAny, etc.
Note that this context gives a capability to functions, therefor requiring it to be present all the way up the stack from the entry point to the call that invokes the dynamic function.
Unfortunately, even the above is not sufficient due to the following case:
function callit(
(function()[_]: void) $f,
)[ctx $f]: void { $f(); }
function breakit(mixed $fun): void {
/* HH_FIXME[4110] */
callit($fun); // whoops
/* HH_FIXME[4110] */
callit(HH\dynamic_fun($fun)); // still whoops
}Therefore, we would also require an additional annotation: whether a function type may contain a value that would trigger a dynamic invocation error if used directly.
In the above case, something like the following would be required:
function callit(<<__MaybeDynFun>> (function(): void) $f): void { $f(); }
Combing the above two features allows us to generally trust the transitive promises of unenforced contexts.
There is technically one further issue, but we do not believe it is a blocking one:
function callit((function(): void) $f): void { $f(); }
function breakit(mixed $var_containing_callit, mixed $fun)[can_dyn_call]: void {
/* HH_FIXME[4009] HH_FIXME[4110] */
$var_containing_callit($fun); // whoops
}Note that in the above case, this is only possible because breakit is marked as can_dyn_call, so even though callit isn’t marked as such, we’re not accidentally introducing a capability not provided by the call stack. This is similar to the question of coercibility for like types.
However, due to the above, we would need to ban passing mismatching function-type args unless the caller’s arg is marked as <<__MaybeDynFun>>.
This work would necessarily be a prerequisite for adding sound capabilities whose calling conventions aren’t guaranteed by the runtime.
Further questions: Does the ability to do $x->foo = $my_fun where $x is unknown break this because $foo could be not marked as __MaybeDynFun? Do we need to hard ban this? What does this mean for generics, reified generics, and higher kinded types?
Even with all of the above, it might not generally be possible to guarantee safety in all cases, meaning that we can’t soundly roll out unenforced contexts.
Future possibilities:
One can imagine any number of applications for this feature. This is quite a powerful and flexible feature that can be used to represent a number of things, depending on our wishes. Some ideas are given below.
Use Capabilities to Model Async/Await
Rather than the current version:
async function gen_do_things(): Awaitable<void> {
await gen_other_things();
}We could model the permission to use await as a capability:
function gen_do_things()[async]: Awaitable<void> {
await gen_other_things();
}Besides uniform treatment (resulting in a simpler overall system), this would allow for abstracting over synchronicity (i.e., async vs normal). E.g., the following snippet would type-check:
function do_later((function()[_]: void) $work)[ctx $work]: void { ... }
function with_async_io()[async, io]: void {
do_later(() ==> print("quick")); // captures `io` capability
do_later(() ==> await slow_fun()); // captures `async` cap.
do_later(()[async] ==> await slow_fun()) // explicitly has `async` cap
}One could further use dependent contexts and have fine-grained callbacks:
function wrap_callback(
(function()[_]: void) $f
):(function()[ctx $f, io]: void) {
return () ==> {
$ret = $f();
print("done");
return $ret;
};
}
function demo_callback(): void {
wrap_callback(() ==> 42);
wrap_callback(()[async] ==> await slow_fun());
}Modules
Modules are a proposed Hack feature that would enforce the boundaries between Prod, Intern, and Test code. The idea is that the typechecker would prevent developers from calling from Prod into Intern or Test because that code is not available on production machines and would otherwise fail at runtime. This feature can be easily implemented using capabilities. Prod code simply would not have the permission required to call into intern or test code.
// file_in_prod.php
module Prod; // implicitly gives [prod] context
function calls_into_intern()/*[prod]*/: void {
callee_in_intern(); // ERROR
}
function callee_in_prod()/*[prod]*/: void { ... }
// file_in_intern.php
module Intern; // implicitly gives [intern] context
function calls_into_prod()/*[intern]*/: void {
callee_in_prod(); // OK
}
function callee_in_intern()/*[intern]*/: void { ... }Ability to log to Stdout/Stderr
An unfortunately common mistake is one is which a developer adds a slog
statement within their code for the purpose of debugging but
then forgets to remove it before committing. We could make the capability
to log to stderr/stdout represented via the context system, such that most
code utilizing it would have a hack error, rendering it uncommittable.
A debugging or development environment could conjure this capability
to facilitate quick development cycle (see the previous subsection).
Levels of Dynamic Coercibility
In the forthcoming like-types project, there is the concept of coercion for dynamic values. In the base version, there will be some ability for a dynamic value to be passed where another is expect. Much of this is still up in the air, but one exactly is passing a dynamic value into an enforced typehint.
There has been some discussion of having a way to turn off that implicit coercion. We could present this as a capability represented via capabilities, regardless of the desired default.
Flow-sensitive capabilities
For throws context to be fully practical, we would need to refine the
local and calling capabilities depending on the language constructs.
Specifically, a try-block would introduce the Throws capability
regardless of whether the function provides it, and the keywordthrow would require that capability or error otherwise, e.g.:
function try_once<T> {
(function()[throws<Foo>]: T) $f
)[rand]: ?T { $t = null;
try { // adds Throws<mixed> as a local & calling capability
if (coinflip()) {
throw "ok"; // {Rand, Throws<mixed>} ⊆ {Throws<mixed>}
} else {
$t = $f(); // ok: {Rand, Throws<mixed>} ⊆ {Throws<mixed>}
}
} catch(...) { // Throws<Foo> is no longer available here
throw "bad"; // error: {Rand} ⊈ {Throws<mixed>}
}
return $t;
}It is questionable if the run-time would every support throwing, since
capability violations result in exceptions (chicken and egg problem).
However, the typechecker could track the capabilities as follows:
function try_once<T> {
(function()[throws<Foo>]: T) $f
)[rand]: ?T {
$t = null;
// $#local_capability = Rand
try { // $#local_capability = Rand & Throws<mixed>
if (...) {
throw(/* $#local_capability */) "ok";
// $#local_capability = Rand & Throws<mixed>
// <: Throws<Foo>
}
// $#capability = $#capability & $#local_capability
// == Rand & Throws<mixed>
$t = $f(/* $#capability */); // ok: Rand & Throws<mixed>
// <: Throws<mixed>
// <: Throws<Foo>
} catch (...) { // $#local_capability = Rand
throw(/* $#local_capability */) "bad"; // error
// $#local_capability = Rand </: Throws<Foo>
}
return $t;
}Hphp Hack Doc HIPs Converging Constants (`hphp/hack/doc/HIPs/converging_constants.md`)
Status: This is not a full HIP following the usual template/process, but shared as a HIP for external visibility.
Converging constants in Hack and HHVM
I’d like to ease a few HHVM restrictions on type constant overriding, and along the way to consolidate the rules around constant inheritance in general.
Motivation
The attribute-based approach to conditional purity (and conditional reactivity) worked like this.
abstract class A {
<<__Pure, __OnlyRxIfImpl(I::class)>>
abstract public function f(): void;
}
interface I {
<<__Pure>>
public function f(): void;
}
class C extends A implements I {
<<__Pure>>
public function f(): void {}
}
class D extends A {
// impure
public function f(): void {}
}The __OnlyRxIfImpl attribute says that a method invocation on a value of type A is pure only if that value is also an instance of interface I. Under coeffects, we express this conditional purity with an abstract context constant.
abstract class A {
abstract const ctx C = [defaults];
abstract public function f()[this::C]: void;
}
interface I {
const ctx C = [];
}
class C extends A implements I {
public function f()[]: void {}
}
class D extends A {
public function f(): void {}
}HHVM doesn’t allow non-abstract constants in an interface to conflict with non-abstract constants in the parent class, and since abstract type constants with defaults are simple non-abstract constants at runtime, we can’t use the current HHVM constant semantics to express the definition of class C.
Background
Note: I’ve compacted class definitions as I think the examples are easier to read this way.
Constants in HHVM can be overridden in subclasses, which is a behavior inherited from PHP.
class A { const int X = 3; }
class B extends A { const int X = 4; /* allowed*/ }Hack allows this behavior to match the runtime. However, if an interface declares a constant with the same name, then we have an error when the subclass is loaded.
class A { const int X = 3; }
interface I { const int X = 4; }
class B extends A implements I {} // runtime errorIf X were instead an abstract constant, then class B can be defined.
abstract class A { abstract const int X; }
interface I { const int X = 4; }
class B extends A implements I {} // okOf course, if the interface weren’t present, class B would have to define the constant itself to satisfy the requirements of A.
**Type Constants**
The type constants feature of Hack, which provides a form of path-dependent types via where constraints, was built on top of class constants. A type constant can be thought of as a class constant whose value is a type structure. As such, the overriding rules for type constants in the runtime are the same — the following code runs without error.
class A { const type T = int; }
class B extends A { const type T = string; } // runtime ok, Hack errorFor type safety, Hack bans this overriding behavior. We’ve reached our first major point of divergence between Hack and HHVM.
There is an escape hatch in Hack, however, in the form of partially abstract type constants. These type constants have an as constraint and their values can be overridden in a subclass. At runtime, the as constraint is simply erased and the constant is interpreted as a regular, non-abstract type constant. In other words, the following example is identical to the previous example from the runtime’s perspective.
class A { const type T as arraykey = arraykey; }
// runtime sees `const type T = arraykey;`
class B extends A { const type T = int; } // no Hack error, runtime remains okThis is convenient to developers, who use the feature to define a value for a type constant in a superclass and only override it in a few subclasses, but it adds complexity because it requires Hack to make judgements about the type this::T based on the exactness of types.
I added the ability to define default values for abstract type constants.
abstract class A { abstract const type T = arraykey; }
// runtime sees `const type T = arraykey;`
class B extends A { const type T = int; }At runtime, this uses the same machinery as partially abstract type constants, which allows for drop in replacements without risking behavior changes. In the typechecker, abstract type constants are generally much better behaved than partially abstract type constants. They cannot appear in concrete classes, ban direct references via self:: and A::, and cannot be read with type_structure. Without partially abstract type constants, this::T can be unambiguously resolved in concrete classes.
Unfortunately, the combination of all the implementation details discussed above thereby prevents the following convenient use case.
abstract class A { abstract const type T = arraykey; }
interface I { const type T = int; }
class B extends A implements I {} // runtime errorThis is the mirror of the context constant example discussed in the motivation section.
Finally, traits now support constants. Their previous ban of constants could be trivially circumvented by having the trait implement an interface. However, if a constant conflict comes from an interface via a trait, the conflict is silently dropped. With this in mind, the prior interface conflict example can be side-stepped using a trait.
class A { const int X = 3; }
interface I { const int X = 4; }
trait T implements I {}
class B extends A { use T; } // no runtime error!Proposal
We use the abstract keyword instead of the presence of a value to denote abstractness for type constants in HHVM. We have committed changes to expose this in HackC and HHVM.
We require that any classlike (class, interface, trait) has at most a single, canonical concrete constant for a given name. If there is a conflict in two defaults for an abstract type constant, we require the developer to redeclare the desired one. Finally, if there are two conflicting defaults but also a single concrete type constant in the hierarchy, the concrete one wins without error.
Trivial, existing behavior
hackabstract class A { abstract const type T = int; } class C extends A {} /* const type T = int; */ class D extends A { const type T = string; } class X extends C { const type T = string; // error, cannot override concrete C::T }Inherited concrete winning over a inherited abstract
hackabstract class A { abstract const type T = int; } interface I { const type T = string; } class C extends A implements I {} // ok, C::T = I::T class D extends A implements I { const type T = int; // error, conflicts with I::T }Two conflicting defaults
hackabstract class A { abstract const type T = int; } interface IAbs { abstract const type T = string; } class C extends A implements IAbs { // error, must declare a concrete T } abstract class D extends A implements IAbs { // error, must redeclare T and choose a default // (can do abstract or concrete) }Two conflicting defaults, but concrete wins
hackabstract class A { abstract const type T = int; } interface IAbs { abstract const type T = string; } interface IConc { const type T = float; } class C extends A implements IAbs, IConc { // ok, C::T = IConc }Design question: Two conflicting defaults with same default
hackabstract class A { abstract const type T = int; } interface IAbs { abstract const type T = int; } class C extends A implements IAbs { // should this error? }Abstract override concrete (from class)
hackabstract class A { const type T = int; } abstract class B extends A { // error, abstract cannot override concrete abstract const type T = string; }; interface I { // same error require extends A; abstract const type T = bool; } trait T { // same error require extends A; abstract const type T = float; }Abstract override concrete (from interface)
- Note: the runtime doesn’t check
require extendsorrequire implements, so those local errors would be in Hack only. The runtime would error later when the interface is implemented or the trait is used.
hackinterface I { const type T = int; } abstract class A implements I { // error, abstract cannot override concrete abstract const type T = string; }; interface I1 extends I { // same error abstract const type T = bool; } trait T1 implements I { // same error abstract const type T = float; } trait T2 { // same error require implements I; abstract const type T = float; }- Note: the runtime doesn’t check
Abstract override concrete (from trait)
hacktrait T { const type T = int; } abstract class A { // error, abstract cannot override concrete use T; abstract const type T = string; }; trait U { // same error use T; abstract const type T = float; }Two conflicting concretes
hackinterface I { const type T = int; } interface J { const type T = string; } interface K extends I { const type T = string; // error, conflict with I::T; } interface L extends I, J {} // error, J::T conflicts with I::TInherited concrete winning over inherited abstract
hackinterface I { abstract const type T = int; } trait Tr { const type T = string; } class C implements I { use Tr; } // C::T = stringhackinterface I { const type T = int; } trait Tr { abstract const type T = string; } class C implements I { use Tr; } // C::T = intNote that while we are requiring users to redeclare in cases of conflicting defaults, the bounds in the redeclared type constant must still satisfy the all of the bounds in all of the parents. All of the examples above are
as mixed super nothing. Example:hackabstract class A { abstract const type T as arraykey = arraykey; } interface IAbs { abstract const type T as int; } interface IConc { const type T = float; } class C extends A implements IConc { // error, IConc::T = float, but T is `as arraykey` } abstract class D extends A implements IAbs { // error, IAbs::T brings in constraint `as int` // must redeclare T with a default that satisfies this constraint }The first draft of this proposal did not require users to resolve default conflicts, instead taking the last default in the linearization. Feedback was to require explicit resolution and drop the inherited conflicting defaults.
We make type_structure start failing on abstract type constants with defaults, to push them to behave closer to abstract type constants without defaults. We also do the same for direct references via the class. Of course, as with all runtime changes, we start by logging and sampling before moving to hard enforcement.
hackabstract class A { abstract const type T; abstract const type U = int; } type_structure("A", "T"); // this fatals type_structure("A", "U"); // this currently does not function f( A::T $a, // this fatals A::U $b, // this currently does not ): void {}Optional: We ban overriding of class constants in Hack to match the type constant behavior. A benefit of this is that
static::Xandself::Xwill have identical meanings in concrete classes. We then ban overriding of concrete class constants and concrete type constants in HHVM.hackclass A { const int X = 3; } class B extends A { const int X = 4; /* ban */ }Possibly for coherence, we should add default values to abstract class constants as well.
hackabstract class A { abstract const int X = 3; } class B extends A {} class C extends A { const int X = 4; /* override */ }- If we implement this and the previous bullet, the semantics of class constants will become identical to type constants across HHVM and Hack.
Hphp Hack Doc HIPs Data Classes (`hphp/hack/doc/HIPs/data_classes.md`)
Title: Data Classes
Status: Draft
The Problem
The Hack runtime does not know which fields are defined in an arbitrary
shape, and does not enforce them. It also cannot reorder shape fields, and
cannot store them contiguously.
This forces the runtime to use data representations that are slower
and use more memory.
By defining a new data type with runtime enforcement, we can provide a
high performance data structure where HHVM knows the fields.
Design Summary
Data classes are high-performance immutable classes, enforced at
runtime, with syntactic sugar for creation and updates.
Basic Usage
Data classes are defined using a syntax similar to other forms of
classes.
data class User {
string $name;
bool $admin;
?string $email = null;
}Data classes live in the type namespace, so they must have a name
distinct from other classes.
Data classes are instantiated with []. This enables HackC to produce
different bytecodes for normal class instantiation and data class
instantiation.
$p = Person[name = "wilfred", admin = false];Properties on data classes are accessed with ->, consistent with
normal classes.
// Access
$n = $p->name;Data classes are immutable. To create a new data class with slightly
modified fields, use ... splat syntax.
$p2 = Person[...$p, name = "anonymous"];Inheritance
Data classes can inherit from other data classes with single
inheritance. Child classes may only add new fields.
abstract data class Named {
string $name;
}
data class User extends Named {
bool $admin;
}Only abstract data classes may have children. Abstract data classes
may not be instantiated.
Runtime Type Information
Data classes have distinct type tags. This enables HHVM to enforce
the names and types of properties. These runtime tags may also be
inspected with is and as, consistent with normal classes.
$p is Person; // trueThis enables runtime type enforcement, unlike shapes.
// Type checker error and runtime error.
$p = Person[...$p, name = 123];Function boundaries can also enforce types at runtime.
data class Server {
string $name;
}
data class Person {
string $name;
}
// Type checker error and runtime error.
takes_person(Server[server = 'db-001']);This is nominal subtyping. Note that shapes have structural subtyping,
so takes_person_shape(shape('name' => 'db-001')) produces no errors.
Release and Migration
HHVM will have a feature flag to prevent people using data
classes.
This enables us to ship frontend support (parser, codegen, type
checker) for data classes without accidentally allowing users to use this.
This does not replace any existing Hack features, so we do not
anticipate large scale codemods. Hotspots in performance sensitive
code will be worth migrating. Users may also prefer to migrate shape
code that doesn't heavily rely on structural subtyping.
Implementation Details
Since data classes have a distinct runtime representation, HHVM will
need updating so -> bytecodes work with this new datatype.
Data class splat updates may require a new bytecode entirely.
Alternatives Considered
Structs
Adding a simple 'bag of data' struct was also a design we
explored.
record Person {
string $name;
bool $admin;
?string $email = null;
}Since we want a nominally typed data model for performance, it would
not be feasible to codemod shapes code to use structs. We
would end up with a larger programming language with more features
that users must learn.
This also makes it difficult for us to explore adding methods in future.
Alternative update syntax
$p2 = clone $p with name = "anonymous", email = "[email protected]";Whilst clone is legal PHP/Hack syntax, it's not well known. The name
is also unfortunate, as it's a shallow copy. It's more verbose too.
$p2 = $p with name = "anonymous", email = "[email protected]";This syntax would enable good code completion, but has little Hack
precedent. It also means data classes have more distinct syntax from
normal classes, making it hard for us to unify syntax in future.
Alternative instantiation syntax
We plan to emit a different bytecode for data class
instantiation. This requires syntax that unambiguously represents a
record instantiation.
This bytecode exploits that data classes cannot be in a partially
constructed state, because they don't have constructors. HHVM can
treat it specially.
$p = Person(name = "anonymous");
$p2 = Person();This is ambiguous with function calls when all the properties have
default values.
$p = new Person(name = "anonymous");
$p2 = new Person();This is ambiguous with normal classes when all the properties have
default values.
Reusing class declaration syntax
final data class User {
public string $name;
public bool $admin;
public ?string $email = null;
}This is significantly more verbose for limited benefit. We want this
to be a compelling alternative to shapes, which have a lightweight
syntax.
Open Data Classes
// '...' allowing additional fields, like shapes.
data class OpenUser {
string $name;
bool $admin;
...
}Similar to open shapes, this would additional fields to be added
dynamically.
This would require additional runtime work, and wouldn't enjoy the
performance benefits. We believe that an explicit extra property is
sufficient.
data class OpenUser {
string $name;
bool $admin;
shape(..) $extra = shape();
}Open Questions
Equality
We'd probably want structural equality for ===.
Person[name = "x"] === Person[name = "x"]; // trueThis is inconsistent with other forms of classes though.
It's also unclear what semantics == should have for data classes.
Serialization
It's not clear how data classes should serialize and deserialize.
Shape Interoperability
It's not clear what APIs we will offer for converting between data
classes and shapes. This is potentially a lossy transform, due to the
nominal typing of data classes and the observable ordering of shapes.
Generics
We have avoided considering generics so far, to keep the typing rules
simple. We're hoping to ship v1 without generics.
This is definitely a feature we want to add after v1 is feature
complete and shipped.
Nested Records
Shapes are copy-on-write, allowing updates of nested items.
$s['x']['y'] = 42;There is no equivalent syntax for data classes, making this use case
more verbose.
`$this` semantics
If we do add methods in the future, it is not clear how we handle$this. Would methods be able to change $this? If not, what can
methods return?
Field Ordering
HHVM has had performance wins by reordering fields in classes. We'd
like to retain that flexibility with data classes. If we really need
observable ordering for data classes, it should probably be opt-in.
Prior Art
C# has value
types
which are introduced with the struct keyword. They do not support inheritance.
Scala has case
classes. They are
immutable classes with public properties, and also provide a .copy
method for creating modified copies. They use structural equality.
Kotlin has data
classes. They
support inheritance and interfaces, and automatically provide equality
and copy methods.
Hphp Hack Doc HIPs Duplicate Enum Values (`hphp/hack/doc/HIPs/duplicate_enum_values.md`)
Feature Name: Reject duplicate enum member values
Start Date: 2026-06-19
Status: Draft
Summary:
Make it a typecheck error for two members of the same enum to declare the same
value, including when one of the members comes from a use-included enum.
enum Color: int { Red = 1; Green = 1; Blue = 3; } // Green has the same value as RedToday such enums typecheck cleanly ("No errors") and only fail at runtime:HH\BuiltinEnum::getNames() builds a value-to-name map and throws anInvariantViolationException when two members share a backing value, crashing
whatever calls it.
The check is gated behind the check_duplicate_enum_values .hhconfig option.
A core goal is to leave no silent gaps: every enum is either fully checked
or explicitly opted out via an attribute. An enum is opted out when it
intentionally aliases values, or when some of its values are unverifiable (a
constant reference, arithmetic, or another form the check cannot compare);
otherwise an unverifiable value is itself an error (see User experience).
Feature motivation:
Duplicate enum values are a recurring source of production SEVs.HH\BuiltinEnum::getNames() throws an InvariantViolationException when two
members share a backing value, and that exception crashes whatever calls it.
Recent incidents:
- S565800 — SEV2,
137K+ affected users (Encrypted Backups, Messenger group-add, Workchat and IGD
broken via a Trusted Caller enum). - S652374,
S623413,
S659583 — SEV3s.
The blast radius is concentrated in logging/event listeners. Instagram's Falco
event listeners are the most frequent victims: hundreds of callsites underwww/flib/logging/Falco/Listeners/ call getNames() to map client-reported int
values to strings, many via the unguarded direct-access patterngetNames()[$value]. When the invariant fires, the whole listener crashes and
logging data for that event is silently dropped — sometimes for hours before
detection.
The root cause is almost always a land-time collision: two diffs land
concurrently, each adding an enum member with the same value. No merge conflict
is raised because the additions touch different lines, so neither author sees
the clash.
Existing tooling does not reliably prevent this: the current lint rule is
best-effort and has structural gaps (see Prior art), so duplicates still reach
production and fail late at runtime, where they are hard to attribute. The
codebase already carries accidental duplicates today (e.g. CodecType, whose
members are all 0), which the check surfaces.
User experience:
With the option on, a duplicate is a typecheck error. So that error-driven
codemods can rely on a consistent shape, the primary position is the enum
declaration, with each colliding member reported as a secondary position:
enum Color: int { Red = 1; Green = 1; Blue = 3; }
// ^ Enum `Color` declares the value `1` for more than one member
// --- Red ----- Green (secondary positions)(Anchoring the error at the enum also handles a clash introduced purely byuse-inclusion, where no local member is involved and pointing inside an unowned
file would be misleading.)
Three situations require an explicit opt-out attribute; otherwise they are
errors:
- Intentional aliasing — the enum deliberately gives two members the same
value. - Unverifiable values — a member's value is something the check cannot
compare (a constant reference, arithmetic, or a non-UTF-8 string literal; see
Implementation details). Skipping these silently would leave holes: a
computed value can collide with a checkable one in an enum thatuses it and
go undetected, e.g.A = 1 << 1in one enum andB = 2in an enum that
includes it. - Downward closure — an enum that
use-includes an opted-out enum inherits
its unchecked/duplicate values, so it must itself carry the attribute. This
keeps the exemption visible at every enum that relies on it rather than
silently inherited (auseof an annotated enum should not quietly grant a
false sense of safety).
<<__AllowDuplicateValues>> // name under discussion -- see below
enum Legacy: int { A = 0; B = 0; } // permitted
enum AlsoLegacy: int {
use Legacy; // inherits a duplicate value, so
} // AlsoLegacy must also be annotated, else errorThe opt-out attribute:
Because the attribute now permits more than literal duplicates — it also marks an
enum whose values are unverifiable — <<__AllowDuplicateValues>> is arguably
too narrow a name. A single attribute covering both cases is sufficient; the name
is open for the review (candidates: <<__AllowDuplicateValues>>,<<__AllowComputeAndDuplicateValues>>, <<__UncheckedEnumValues>>). The
attribute is downwards-closed: a use of an annotated enum requires the
using enum to be annotated as well.
IDE experience:
Surfaces as a standard typecheck diagnostic. No special IDE handling is required.
Implementation details:
- Recording. The direct decl parser records each enum member's value on the
class constant as a discriminated value — int (asi64), string,nameof, or::class— gated by a parser option,include_enum_member_values. Atast
check then iterates an enum's folded constants and flags any value that repeats
an earlier member's, plus (under the "no silent gaps" rule) any unverifiable
member of a non-exempt enum. Because folded decls already mergeuse-included
members, direct duplicates, local-vs-used clashes, and clashes between two
included enums are all caught in one pass. - Canonicalization. Values are normalized so equivalent literals compare equal
(e.g.0x10==16,'a'=="a"). Only int/string literals,nameof, and::classare recorded; anything else (constant references, arithmetic) is
unverifiable. nameofvs::class. These are recorded as distinct kinds and never
collide with each other: at runtimenameof Cis a string butC::classis a
class pointer, so they are not equal. (Class-pointer work by @vmladenov /
@dizzy may make::classineligible as an enum value altogether, in which case
this case disappears.)- Non-UTF-8 string literals. Treated as unverifiable: a non-UTF-8 byte string
has no lossless comparable canonical form, so recording it risks two distinct
byte strings collapsing to the same key and being falsely flagged as
duplicates. - Opt-out. The attribute makes the check skip the enum and the parser drop its
recorded values; it is downwards-closed (see above). - HHVM changes. None required.
- Corner cases. Enum classes are out of scope (plain enums only).
- Codemodding. Existing duplicate-value and unverifiable-value enums must be
fixed or annotated before the check is turned on repo-wide.
Migration scale:
The "no silent gaps" rule means two populations need migrating before rollout:
enums with duplicate values, and enums with unverifiable values. To size this
(asked by reviewers), we will gather from a local run over WWW:
- the number of would-be duplicate-value errors, and the share that are in
generated code; - the number of enums with unverifiable/computed values (the additional
migration "no silent gaps" implies); - coverage: the fraction of enum members that are checkable (int/string/
nameof/::class) vs unverifiable.
(Numbers pending; to be filled in from a local hh run over WWW.)
Design rationale and alternatives:
- Typecheck-time vs runtime. The bug is severe and currently only fails at
runtime; catching it statically is the whole point. - No silent gaps (error on unverifiable values rather than skip). Silently
skipping unverifiable values leaves holes — a computed value can collide with a
checkable one in an enum thatuses it, undetected. Requiring those enums to
carry the opt-out makes every enum either checked or explicitly exempt. This is
cheap to implement (the decl already records nothing for unverifiable members,
so the check just flags such a member of a non-exempt enum) at the cost of a
larger migration. It also answers "why should the attribute affect computed
values at all": because computed/unverifiable values are now errors by default,
not silently allowed — the attribute is exactly what permits them. - Canonical-value comparison vs full constant evaluation. Comparing recorded
canonical literals keeps the check simple and predictable. Resolving arbitrary
constant expressions (e.g.X = Foo::BARor arithmetic) was considered and
dropped. Values are recorded by the direct decl parser -- a fast, syntactic,
per-file pass that intentionally neither resolves references nor evaluates
expressions. Full evaluation would mean building a constant evaluator inside
that parser and resolving references across decls (looking up the referenced
constant's own decl, often in another file). That cross-decl resolution also
adds fanout: an enum member's recorded value would then depend on the
referenced decl, so editing that decl would have to re-record the enum and
re-run the check -- enlarging the decl's dependency footprint and the
incremental-recheck cost. The "no silent gaps" rule lets us be safe without
paying for evaluation: unverifiable values are surfaced (via the required
attribute) rather than resolved. - Error, not warning. A warning would be easy to ignore for a bug this likely
to break at runtime. - Opt-out attribute vs always-on. A few enums legitimately alias values or use
unverifiable values; the attribute is a precise, visible escape hatch rather
than disabling the check globally.
Prior art:
- Aurora lint rule
DUPLICATE_ENUM_VALUES(DuplicateEnumValuesLintRuleinwww/flib/intern/aurora/lint_rules/ast_linters/) already flags duplicate enum
values, but as best-effort lint it has structural gaps that let collisions
through: it does not run on generated files or on files too long / that fail to
parse, and catching collisions introduced across files via enumuse-inclusion
has been a persistent struggle. The typecheck-level check closes these gaps —
it reads the folded decl (includinguse-merged members) for every enum,
generated or not, and reports before the duplicate can land. - Runtime-resilience proposal:
RFC: Make Enum::getNames() resilient to duplicate values
proposes makinggetNames()tolerate duplicates (a fallback string) instead of
throwing. That is complementary rather than competing: resilience limits the
runtime blast radius, while this proposal prevents duplicates from landing at
all — the two can coexist. - Prior discussion:
Duplicate enum value - Hack Warning?.
Drawbacks:
- Existing duplicate-value and unverifiable-value enums must be fixed or
annotated before rollout (size TBD — see Migration scale). - Decl memory footprint. Recording a value per enum member enlarges decls.
Measured full-init peak memory increase is ~+1.3% (a point estimate, not
statistically significant at 99%); ints are stored as an 8-bytei64rather
than a string, and a member without a recorded value costs only an empty
option slot.
Unresolved questions:
- Rollout plan and the timeline for turning the check on by default.
- The final attribute name (see The opt-out attribute).
Future possibilities:
- Resolve simple constant references to widen coverage, turning some
currently-unverifiable values into checkable ones and shrinking the migration.
Hphp Hack Doc HIPs Enum Class Labels (`hphp/hack/doc/HIPs/enum-class-labels.md`)
HIP: Enum Class Labels
Status: Candidate
Depends on: Enum Classes project.
Implementation status: Done. Depends on a couple of lags
Enum classes flag, now enabled by default. (HHVM:This is now removed-v Hack.Lang.EnableEnumClasses=1)Unstable FeaturesNo longer an unstable featureenum_class_label(HHVM:-v Hack.Lang.AllowUnstableFeatures=1)commonthis flag has been removed from the parser.disallow-hash-commentsflag
Motivation
Enums and Enum classes are sets of values. Enum constants and enum class values can be accessed via bindings, but the bindings themselves do not have denotable expressions. Consider for instance:
enum E : int {
A = 42;
B = 42;
}
function f(E $x): void {
switch ($x) {
case E::A: echo "from A I get "; break;
case E::B: echo "from B I get "; break;
}
echo "$x\n";
}
function test(): void {
// Both echoes "from A I get 42"
f(E::A);
f(E::B);
}In the code above, E::A and E::B are values of type E and the programmer used the switch statement to recover which binding was used to access the enum constant bound to $x. However, since the runtime is only aware of enum constants and not bindings, in both invocations of f the runtime will always match the constant 42 (bound in both cases to $x) against the constant 42 (obtained via E::A in the first case statement): in both invocations the first case statement is selected and "from A I get 42" is displayed twice.
While using Enum classes to replace codegen with generic programming, we faced some situations where we needed these bindings to be denotable and first class. Neither enums, nor enum classes, provide this at the moment. We also realised that using enum classes in www increases verbosity compared to the old codegened APIs: a call to an “old-fashioned" codegened getName() method might now look like get(MyLongClassNameParameters::Name).
Proposal
This proposal introduces the concept of labels for enum class constants. Fully qualified labels are new expressions of the form Foo#Bar, where Foo is the name of an enum class and Bar is the one of Foo’s constant. Labels provide first-class bindings for enum classes and can be used in two new builtin functions to obtain the name and value of the corresponding enum class constant. Labels also have a short syntax, of the form #Bar, that limits verbosity whenever type inference can be leveraged to reconstruct internally the full Foo#Bar label.
Expressions & types. A label from the enum class Foo binding a constant to the type T has type HH\EnumClass\Label<Foo, T>. The new type HH\EnumClass\Label is opaque.
enum class E : I {
C Name = ...;
}
E#Name : HH\EnumClass\Label<E, C>
$x = E#Name; // ok
f(E#Name); // okIn practice, Hack often has enough information to infer the correct enum class name, so we can make it implicit: the f(E#Name) call can then be shortened as f(#Name). When type information are not sufficient, Hack reports an error and asks for a fully qualified annotation. We currently only allow the short form in some function calls, but we plan to allow more locations in the future.
$x = #Name; // error, not enough context to infer the enum
f(#Name); // ok, because Hack knows f's first arg type. It behaves like f(E#Name)New builtin functions. This proposal extends the definition of enum classes to provide two new static methods that can consume labels:
nameOfwhich takes a label and returns a string representation of itvalueOfwhich takes a label and returns the value associated with this label
Here is a short example of their use:
function show_label<T>(HH\EnumClass\Label<E, T> $label) : void {
echo E::nameOf($label);
}
function get<T>(HH\EnumClass\Label<E, T> $label) : T {
return E::valueOf($label);
}In this example:
show_label(E#Name)will display a string representation ofE#Name. For now, it would display the stringName.get(E#Name)returns the valueE::Name.
As long as Hack can infer the enum class name, fully qualified and short expressions can be used transparently:
Since get(#Name) behaves like get(E#Name), it will return the same value E::Name.
Sugar coating requested by Beta testers
As requested by some beta testers, we also provide an alternative way write the special case where the first function argument is a label, allowing to write f(#X, ...) as f#X(...). This will enable the developers to use a syntax closer to the old-fashioned getName(), by writing get#Name().
Note that this is only syntactic sugar and does not provide additional possibilities like taking a function pointer or a lambda: get#Name<> is forbidden, as it would be a function pointer on a partially applied function.
IDE Integration
- Automatic completion provides constant names when writing a valid prefix like
getParam#(implemented by Vassil) - Documentation hover shows as hover types with doc for labels and function calls sugar like
get#Name()(implemented by Tom)
Implementation
Forewords
It is important to note that until recently, the # symbol was a marker for a one line comment. This has been removed, and the # symbol is now reused by this proposal. This is not a limitation as Hack still supports one line comments using the // syntax.
Runtime
This proposal introduces two new expressions in the AST, the fully qualified enum class labels (E#Name) and the short form (#Name). The current sample runtime implementation works as follow:
- both fully qualified labels and short labels are compiled to the
Namelabel identifier, discarding theEpart; - the base class of enum classes,
BuiltinEnumClass, is extended with two new final static methods,nameOfandvalueOf. Given a label identifier, and the class name they are invoked on, these can perform name and value resolution via a special HHVM intrinsics.
Type checking
To type fully qualified enum class labels, we introduce a new type HH\EnumClass\Label<-Tenum, Tdata> which reflects the enum class where the label is defined, and its associated type. The variance of the first type parameter is the same as the one for the HH\MemberOf type, introduced by enum classes. Please refer to the enum class HIP for a detailed discussion of variance.
The use of fully qualified enum class labels does not require any type checker change. The shorter version instead needs minor changes to type checking function calls. We detail how Hack’s type inference is modified to reconstruct the enum class name.
Let us do one step backward and consider Hack's handling of function calls. At any point in a program, Hack has access to the signature of all previously defined functions. Suppose that Hack is inferring the type of the expression f(expr):
- it already knows the type of the top level function/method
ffrom the context :A -> B - it recursively infers the type of
expr:C - it checks that
Cis a valid subtype ofA:C <: A - if that’s the case, the whole expression has type
B
In general the type of f is not needed to infer the type of expr. However, when short syntax is encountered, the type information about f arguments is used to infer the type of a label. As an example, let us consider again the get function:
function get<T>(HH\EnumClass\Label<E, T> $x) : TWhen inferring the type of get(#Name) Hack can perform the following steps:
- from the context, it knows that
gethas typeHH\EnumClass\Label<E, T> -> T - it recursively infers the type of
#Name, knowing that a constant fromEis expected - there is indeed a constant named
Namein the enum classEso the call is valid - the full expression will thus behave as if the developer wrote
get(E#Name)
We currently support this mechanic only for function call when the top level type is a Label. In the next section, we’ll discuss ways to support the short syntax in more locations, but this HIP only guarantees the support for function calls.
Future extensions
Extension to enumeration types
Standard enumeration types could benefit from the same usage of fully qualified and short labels. In this proposal, we only focus on enum classes, but the similar machinery might be ported to enums, and extend the expressivity of labels to them. Since there is currently no demand for it, we restrict this feature to enum classes for now.
The main differences we expect are:
- A dedicated label type,
HH\Enum\Label<Tenum, Tdata>with different constraints on the type parameter (Tenumwill probably be invariant since enum inclusion does not entail subtyping, andTdataconstraint byarraykey) - The
nameOfandvalueOfmethods would have to be defined in theBuiltinEnumclass, and the signature ofvalueOfwould need to be adapted.
Allowing short syntax at more locations
Internally Hack keeps an optional expected type information when type checking expressions. We plan to use this information to allow the short labels more broadly, e.g. in return statements.
Performance sensitive code
The current proposal only addresses the verbosity issue for labels. For enum class constant themselves, the full name must still be passed to functions that expect constants, not labels, as in the example below:
function get<T>(HH\MemberOf<E, T> $member) : T {
return $member;
}
get(E::Name); // ok
get(#Name); // not allowedA solution to this issue is to call valueOf by hand and rewrite this get method like this:
function get<T>(HH\EnumClass\Label<E, T> $label) : T {
return E::valueOf($label);
}
get(E::Name); // not allowed
get(#Name); // okIn performance-sensitive code, this extra function call might be too expensive. If such code exists and the shorter enum class invocation syntax needs to be supported, we might provide an attribute that enables the short notation for constants too, eg.:
function get_via_label<T>(<<__ViaLabel>>HH\MemberOf<E, T> $member) : T {
return $member;
}
get(E::Name); // not allowed
get(#Name); // okThe logic added to deal with labels has been added at function definition sites: until more type information is available at compile time, or information from HHBBC can be exploited, we cannot support <<__ViaLabel>> by instrumenting call sites. Paul Bissonnette has ideas for that, but it won’t be discussed in this proposal.
We are emitting a special byte code sequence when __ViaLabel is used to transform the label (just a constant name) into the right class constant access, in the function prologue. This implies that inside get_via_label, even if called with the label #Name, $member is the constant value E::Name, not the label. There is no way to fetch back the label.
We currently have a running prototype to test this feature, but I don’t think we should ship this unless there are effectively some performance issues in the framework that will use labels.
Hphp Hack Doc HIPs Equality And Identity (`hphp/hack/doc/HIPs/equality_and_identity.md`)
Title: Equality, Identity, and Comparisons
Start Date: June 24 2020
Status: Draft
Summary
Unify == and ===, introduce sane alternatives for classes that need to use
behaviour similar to the old versions.
Feature motivation
Hack has two main comparison operators == and ===. They're individually
comprised of behaviours both good and bad, and combine to a muddied, confusing,
and overall broken experience. == does coercing value equality, including on
objects, and compares arrays (sometimes unorderedly). === does pointer equality
checks on objects (including collections), and breaks some invariants for
reactive and pure code.
We'd like to simplify and correct the mistakes of the previous design
The current state
===- Objects: pointer equality (note that closures are instances of different objects)
- arrays: compares all elements (keys and values) using
===, requiring identical internal ordering. - values: value equality
- Hack Collections: pointer equality (they are objects)
==- Objects: compares all properties using
== - note that closures still don’t compare equal because they’re instances of different objects
- arrays: compares all elements (keys and values) using
===for keys and==for values, ignoring order fordicts/darrays/keysets - values: value equality following complex PHP coercion rules.
- Hack Collections: compares all elements using
==, ignoring order forMap/Set - If comparing different types will attempt to coerce one side to the type of the other before comparing
- Objects: compares all properties using
<=,>=: Use==under the hood. No===variant.- Sorts and switch statements implicitly use the == behaviour under the hood
User experience
- To test object pointer equality, use builtin
is_same_obj($a, $b)that does a pointer check- Potentially would require a marker interface such as
IIdentityTestable(name to be bikeshed). - In that case, whether
is_same_objis free standing or works as$a→isSame($b)to be bikeshed- In the method case, this is non-overridable
- Potentially would require a marker interface such as
- To get object structural equality require explicit opt-in via an interface such as
IEquatable- To be bikeshed: does this allow using
==or does it do something like$a→eq($b)- In the method case, this is non-overridable
- Would require all properties to be either values or also
IEquatable - Note that this would probably do an identity test first as an optimization
- This also would require reflexivity for the optimization to be sound.
- If we can’t compare Collections and Arrays, how do props of those types work? Do we allow it implicitly in this case?
- To be bikeshed: does this allow using
- For the previous two, if we go the object method route of comparison, absolutely cannot allow overriding implementations.
==works as follows- never coerces the arguments
- Objects: see 1 and 2.
- If
IEquatablemeans can use==, do we return false or throw when not present? - What happens when objects have different types?
- If
- Bikeshed more: Cannot compare arrays/collections. Use HSL or Collections methods
- Values: work the same
- Closures are not
IEquatable, and pointer equality on two of them is always false (or throws?).- This will cause issues with reflexivity.
- bikeshed:
resourceandNaN? Other edge cases?- Note that NaN is already weird. Currently
NaN !== Nanbutvec[NAN] === vec[NAN]
- Note that NaN is already weird. Currently
===doesn’t exist<=,>=, etc inherit the changes to ==- Are
IEquatableobjects comparable this way? I expect not. - Do we allow them on Containers?
- Are
- Having an object-arg to a memoized
pure/rxfunction requires them to beIEquatable - Sorts and switch statements use the new sane == under the hood
- Will likely need a builtin coercing_eq_yikity_yikes() that does the old == behaviour for migration purposes
IDE experience
N/A
Implementation details
Mostly still open questions above or below. Did I miss any?
What the typing rules of == under this proposal? Is it still a function that takes two values of type mixed?
Design rationale and alternatives
TODO once we get more consensus about the open questions above
Drawbacks
TODO
It's a ton of work. Probably not HAM level, but definitely a major project from the HF perspective
Prior art
TODO
Note rust's ord, eq, partial ord, and partial eq. Rust (which took these ideas from Haskell) only allows you to compare values that implement these traits (typeclasses in Haskell).
Unresolved Questions (in addition to the currently inline ones above)
For opt-in structural equality, what if I have a non-opted-in parent class? By opting in do I also opt it's fields in to being inspected? What about traits?
Should switches always use pointer equality on objects? This seems very relevant to enum classes.
How do function pointers work? And are you guaranteed to get the same pointer for two different calls
Does structural equality on well-formed IEquatable classes always work?
class ListNode implements IEquatable {
public function __construct(
public int $val,
public ?ListNode $prev = null,
public ?ListNode $next = null,
) {}
}
function compare_nodes(): void {
// Build a linked list [1, 2].
$n = new ListNode(1);
$n2 = new ListNode(2, $n);
$n->next = $n2;
// What value does $b have?
$b = $n == $n2;
}The obvious recursive function for structural equality would loop infinitely on $n == $n2.
Future possibilities
I think this is mostly N/A? Unless we want to reuse === for something?
Hphp Hack Doc HIPs Gradual Modularity (`hphp/hack/doc/HIPs/gradual_modularity.md`)
Gradual Modularity in Hack
Status: draft, not actively being worked on. If this changes, this pre-HIP proposal should be updated to match
the current HIP template.
Last updated: 2019-10-09
Shared as a HIP for external visibility
The Problem
In production builds of Facebook WWW, certain directories are dropped, such as internal tools and test directories.
This means that any code attempting to access symbols in those directories will fatal at runtime with an autoloader fatal. Hack is not currently able to enforce this.
As a related problem, due to the global nature of our repo, framework developers struggle to hide the implementation details of their frameworks. Often, we rely on naming conventions like _DO_NOT_USE. But, again, there is no static analysis to enforce this, and these symbols end up getting used anyway.
Then, there are similar problems that arise when attempting to define 'black box' APIs - we may want hard isolation while maintaining access to core infrastructure: for example, there may be a blessed library to interact with a
data store, and direct access should be banned except by the library.
Lastly, there's the classic problem of open-sourcing Hack frameworks with a source of truth as part of a proprietary monorepo: for example, all of Facebook WWW can use the HSL, but the HSL can not depend on any proprietary code.
These problems are manifestations of a more general problem: the lack of modularity in the Hack language—everything is globally accessible. Upon closer inspection, we can see that there are multiple granularities to this problem: two examples are the "environment" level and the "library" level.
- Environments represent build-time partitioning of the repository. The 'intern' (internal tools and libraries)/production boundary is a classic example: code in intern may access code in prod, but not vice-versa. This is most akin to the workspaces feature of modern package managers (see Prior Art).
- Libraries are logically related groups of code. These are like modules/packages that we see in most modern languages. Libraries may selectively hide their internals from their dependencies.
Note that these granularities are not mutually exclusive—for example, HSL is both a library and an environment.
In the same way that we slowly introduced types into an untyped Hack, we can introduce modularity gradually, from coarsest to finest granularity. Accordingly, this document explores a solution to the problem of "environments," while leaving the possibility open to solving the "libraries" problem in the future.
Solution Requirements
- Discreet. We desire a limited number of environments in Facebook's WWW codebase, to retain the advantaged of monorepo development. Environment definitions should be hidden from developers, and should be hard for developers to subvert. Essentially, this means that any per-file syntax is undesirable.
- Hierarchical. Environments should be able to depend on others, where "environment A depends on environment B" is defined as: code from A may reference code from B, but code from B may not reference code from A. This allows, for example, Intern to access Prod code but not vice-versa. Dependencies are not transitive: i.e. if A depends on B and B depends on C, A does not implicitly depend on C.
- Permeable. While environment dependency is generally one-way, we must have a type-safe way of piercing that boundary (for example, some Intern symbols can be accessed from Prod). This implies that the feature should be first-class-ish enough for the typechecker/compiler/runtime to interact with it. This requirement is intended to be a WWW-only compromise, so we can tolerate it being clunky to use as a deterrent.
- Single Source of Truth. Environment specifications should be able to be consumed by the typechecker (to statically enforce the boundaries), the runtime (to dynamically enforce the boundaries), and the build system (to selectively include code in the build). This way, the typechecker has an accurate view of reality.
Abstract Proposal: Environments
This section describes the environments feature in an abstract sense, using pseudocode, to bring the semantics of the feature to the forefront. Later sections propose a concrete syntax for the feature.
An "environment" is an isolated subdivision of a monorepo, where "isolated" means that code defined outside of a particular environment may not access code within that environment (barring certain exceptions). The boundaries are enforced both by the typechecker and the runtime.
The build system will select environments to include in the build, but all environments do not have to correspond to a hard runtime boundary. This allows environments to be used for massive sections of a repository (e.g. intern/prod), but also for libraries (e.g. HSL).
Defining Environments and Builds
An environment is specified inside a special configuration file in a directory. A special filename isn't strictly necessary, but it makes environments easy to find within a codebase containing extremely large numbers of files, and makes it harder for users to accidentally define environments.
// Example: in ~/www/flib/environment.x:
environment {
name = Prod
}All code in source files in said directory and recursive subdirectories belongs to this environment. A source file belongs to exactly one environment: the nearest one defined. For example, if flib/ defines an environment "Prod" and flib/intern/ defines an environment "Intern", then all code under flib/intern/ belongs only to Intern. Source files which do not have a nearest environment definition belong to a “default environment”.
Environments can depend on multiple other environments. For migration purposes, all environments implicitly depend on the “default environment,” with the assumption that all code will eventually move to a defined environment. To define an environment that depends on another, simply define the environment normally and include its dependencies:
// Example: in ~/www/flib/intern/environment.x:
environment {
name = Intern
dependencies = [Prod]
}As with the first example, all code under flib/intern/ belongs only to Intern. However, now that Intern depends on Prod, code belonging to Intern may access code in Prod, but not vice-versa! The dependency relationship is not transitive; for example, if there were an environment "Scripts" that depended on Intern, it would only be able to access code in Prod, if it also depended on Prod.
That flib/intern/ is a subdirectory of flib/ is irrelevant—dependent environments don't have to be defined in subdirectories of the environments they depend on. Conversely, Intern doesn't have to depend on Prod just because flib/intern/ is a subdirectory of flib/. In fact, the relationship can be inverted—for example, Prod could depend on an "HSL" environment defined in flib/core/hack/lib/!
All environments implicitly depend on the aforementioned “default environment”. This allows for environments to be gradually migrated into a monorepo, without having to do it all at once. It also handles the case of HHVM builtins, which are currently provided by HHIs. Eventually, they may all be inside of a “builtin” environment, but initially they will simply live in the default environment and be accessible everywhere.
Given that __tests__ are scattered around the codebase currently, any proposed feature would be restricted to running regexes on filenames, which would hamstring its ability to integrate into the language. Ideally, this feature assumes that tests in WWW are moved from __tests__ directories to one top-level tests/ directory. But, we may be able to reach a compromise if one environment.x file is able to define multiple environments via regular expressions that only apply to files in that directory.
// Example: in ~/www/flib/environment.x:
environments {
ProdTests {
regex = "#/__tests__/#"
dependencies = [Prod]
}
Prod {
regex = "#.*#"
}
}A “build” is a collection of environments that indicate the code that’s available in a particular build of the repository. It is specified inside a special configuration file in the root directory of the repository. For example, Facebook’s builds might look like this. Note that our builds evolved to be hierarchical, allowing us to only specify one environment per build, but it's not necessary.
// Example: in ~/www/builds.x:
build {
name = Prod
environments = [Prod]
}
build {
name = Intern
environments = [Intern]
}
build {
name = Scripts
environments = [Scripts]
}Permeability
There are two cases of issues in which existing boundaries in WWW are violated. The first issue is simpler than the other: certain classes are defined in Intern, but really should be in Prod. The solution to this problem is to make environments typechecker-only initially, and use the static analysis to move definitions to where they need to be before turning on runtime enforcement.
The second issue is trickier. Sometimes, definitions can't be moved, explicit checks on the current environment at runtime, which explicitly break the abstraction. To support this permeability, we need to be able to introspect on which environments are available in the current build.
// Example: in some prod file:
if (<current build includes Intern>) {
// Access intern code here...
}Or, if we’re refining with some other mechanism (for example, being is a script context implies that the Scripts environment is available), then users can assert an environment is available with invariant to provide a useful error message.
// Example: in some prod file:
if (Environment::isScript()) {
invariant(
<current build includes Scripts>,
'Being in a script implies that Scripts is available',
);
// Access scripts code here...
}Enforcement & User Experience
Environment accessibility is enforced whenever an identifier is referenced. Enforcement is based entirely on the source files of the “caller” and “callee”—the permeability construct doesn’t affect functions called within a permeability block.
// Example: in some prod file:
class ProdClass extends InternClass // Error
implements InternInterface { // Error
use InternTrait; // Error
}
function f(mixed $x): void {
intern_function(); // Error
intern_function<>; // Error
InternClass::SOME_CONST; // Error
h<InternClass>(); // Error
new InternClass(); // Error
$x is InternClass; // Error
if (<current build includes Intern>) {
intern_function(); // OK
intern_function<>; OK
InternClass::SOME_CONST; // OK
h<InternClass>(); // OK
new InternClass(); // OK
$x is InternClass; // OK
g(); // Note that the usages in g() are still errors
}
}
function g(): void {
intern_function(); // Error
echo InternClass::SOME_CONST; // Error
// etc...
}
function h<reify T>(): void {}When typechecking a file, the typechecker maintains a list of available environments. Initially, the list has exactly one element. Whenever a symbol is referenced, the typechecker compares the environment of the current source file against the environment of the referenced symbol. If they’re incompatible, an error is raised. The permeability construct will add the checked environment to the otherwise singleton list of current environments, and the typechecker will iterate over the current environments within the permeating block.
When a boundary violation is found, the typechecker's error message will list the two environments, including an explanation as to why each source file is in its respective environment. The explanation can be computed from the two ways environments can be defined (directory plus optional string pattern). However, the error message will not point to the environment file, as we do not want to advertise this feature to WWW users (but they can easily find it by looking in the directory specified).
For example, consider the environments defined above (Prod, ProdTests, Intern). If code from Intern attempted to access code from ProdTests, the error message would say:
Cannot reference a symbol in another environment
This use-site is in the Intern environment because it is in directory flib/intern/
The symbol X is in the ProdTests environment because it is in directory flib/ and matches the pattern __tests__
Intern does not depend on ProdTestsIn repo-authoritative mode, the current build should be known when the repository is compiled (e.g. we know if we are going to deploy to prod, intern, etc). This means that environments that aren’t part of the build will simply be dropped from the repository, any environment checks on identifiers can be elided, and permeability conditions can be statically checked and compiled out (akin to ifdef). However, if a function is annotated with the __DynamicallyCallable attribute, then the checks must remain, because we don’t know which environments it may be called from.
In sandbox mode, the available environments must be defined per-request, so that they behave identically to how such a request would behave in production. For example, in development, a user should not be able to access scripts in a web request. The native autoloader can be environment-aware, and refuse to load symbols if the environments are incompatible. Similarly to the typechecker, the permeability construct will “refine” the current environment to the one checked within the scope of the checked block.
Some ideas on how to determine the available environments:
- Could introspect on WebController and related frameworks to create a build object
- Could use native autoloader to determine the right build object
- __EntryPoint could have a way of specifying which environments are available
Application in WWW
Philosophy
Facebook has benefited tremendously by betting on the monorepo model. This feature is intended to mitigate some shortcomings of the monorepo without cutting into the benefits. We want to help users understand when they’re writing code that will behave differently in production, but we do not want to encourage carving up the WWW repo into silos. We want to avoid a world in which developers do not feel empowered to contribute to parts of the codebase because it “belongs” to someone else. We also don’t want to enable an intractable dependency graph to develop in the WWW repo.
Therefore, for the initial rollout of this feature, we will only allow environments that correspond to a form of hard isolation (e.g. a build boundary, or an OSS library). Framework maintainers who wish to hide implementation details will have to wait—environments are not intended to be a packages feature.
Rollback Plan
What if we decide that environments aren't the right solution for WWW? Perhaps they'd proliferate too quickly or make the WWW developer experience too cumbersome. Recall this property of environment definitions discussed above:
Source files which do not have a nearest environment definition belong to a “default environment”.
It follows that to roll back to a pre-environment state in WWW, it would be sufficient to delete all environment files from WWW. Then, all declarations would be in the default environment, and would be accessible to each other, both in the typechecker and the runtime (if we get that far before rolling back).
The environment checking code could then be removed from the typechecker, followed by the code that records environments into saved states.
Sample Configuration
This is one possible initial configuration which adheres to the principle defined above:
- **HSL: **The Hack Standard Library, which should depend on nothing except builtins provided by HHVM.
- Depends on:
- Depends on:
- **HSL-Experimental: **Components that are in development for eventual addition into the HSL, but not ready to be used in WWW.
- Depends on: HSL
- HSL-FB: Components that may be promoted to HSL eventually, but are useful for some FB use-cases.
- Depends on: HSL
- **TestInfra: **Components of our test frameworks, such as UnitTest, ExpectObj, etc.
- Depends on: HSL, HSL-FB
- Build Build steps that we may want to isolate from WWW at large.
- Depends on: HSL, HSL-FB
- Prod: WWW at large, anything under
flib/but notflib/intern/.- Depends on: HSL, HSL-FB
- Test environment: Prod-Tests
- Depends on: HSL, HSL-FB, Prod, TestInfra
- Intern: Internal code, anything under
flib/intern/.- Depends on: HSL, HSL-FB, Prod
- Test environment: Intern-Tests
- Depends on: HSL, HSL-FB, Intern, Prod, TestInfra
- Scripts: Scripts, anything under
scripts/.- Depends on: HSL, HSL-FB, Intern, Prod
Then, our three builds would be:
- Prod: includes Prod and dependencies
- Intern: includes Intern and dependencies
- Scripts: includes Scripts and dependencies
Other Considerations
Incremental Typechecking
There are a few fundamental points that determine how environments will affect Hack's incremental typechecking model.
- Environments do not create implicit namespaces. Each name in the monorepo is still globally unique, and is defined in exactly one file.
- We already maintain a best-effort dependency graph between every file in the codebase, so that if a particular file changes, we re-typecheck it and its dependents.
- All source files belong to exactly one environment (either in a user-defined environment or the "default" environment).
Then, most working copy changes related to environments may be reduced down to one or more files moving from environment A to B, in which case we re-typecheck those files and their dependents. This means that the only machinery we must add to the server is watching for environment changes, and mapping those changes to a list of files that moved environments. Consider the following situations:
- If a file moves between environments, that file must be re-typechecked, along with its dependent files.
- **If the name of an environment changes, **it's analogous to every file in the environment moving to the new one. Therefore, all files in that environment must be re-typechecked, along with their dependent files.
- If an environment file is added, it's analogous to all files in that directory moving to the newly-added environment. Therefore, all files under the directory containing the new environment file must be re-typechecked, along with their dependent files.
- If an environment file is removed, it's analogous to all files in that directory moving to the next-closest environment. Therefore everything under the directory containing the deleted environment file must be re-typechecked, along with their dependent files.
- If an environment file is moved, it's analogous to zero or more files being added and/or removed from the environment. Therefore everything under the new and old directories containing the environment file must be re-typechecked, along with their dependent files.
- If the environment's dependencies change, then all files in that environment must be re-typechecked, along with their dependent files.
Another concern is how the addition of environments affect saved states. TODO:
- add an environments table along with the dependencies between them
- add a reference to environment for each file in the naming table.
- Need to do performance testing for O(repo) updates to the naming table when environments change.
Open Source
If each of our OSS libraries will be an environment internally, there is potential to integrate environment specification files with package managers. In order to do so specifications would have to be extended with at least the following information:
- Dependency version: allowing for multiple semver constraints
- Dependency source: e.g. npm, GitHub, this repo (in the case of local environments)
- Separation of production vs. development dependencies
For a specific package manager, the current frontrunner is Esy, Reason's package manager. One large factor in choosing Esy over Yarn is that it's a binary (versus Yarn requiring Node on the machine, or us having to package Node with HHVM). We'd need to implement a plugin to read HDF and make sure environment definitions are flexible enough to be used by package managers, and the developers of Esy have been open to collaborating on the design process.
A pitfall to consider is that Esy and Yarn install packages in a global cache on the system (instead of in the project root). This means that the typechecker will need to be taught about this cache in some way to still work in OSS.
Concrete Syntax Proposal: HDF
For a concrete proposal for environments, we’ll choose HDF. It’s consistent with HHVM’s current configuration format, and it supports dictionaries and lists. Other options considered were YAML, TOML, and JSON, but each were either inappropriate or suboptimal.
To define environments, define named nodes inside an environments field in a file named __environments.hdf in a directory. The special filename isn't strictly necessary, but it makes environments easy to find within a 2.5M file codebase, and makes it harder for users to accidentally define environments. To define an environment that depends on another, simply define the environment normally add a dependencies field:
// Example: in ~/www/flib/__environments.hdf:
environments {
ProdTests {
regex = "#/__tests__/#"
dependencies {
Prod
TestInfra
}
}
Prod {
regex = "#.*#"
}
}
// Example: in ~/www/flib/intern/__environments.hdf:
environments {
InternTests {
regex = "#/__tests__/#"
dependencies {
Intern
TestInfra
}
}
Intern {
regex = "#.*#"
dependencies {
Prod
}
}A "build" represents a concrete subdivision of a monorepo which is composed of environments. To define builds, define named nodes inside a builds field inside a file named __builds.hdf in the root directory of the repository.
// Example: in ~/www/__builds.hdf:
builds {
Prod {
environments {
Prod
}
}
Intern {
environments {
Intern
}
}
Scripts {
environments {
Scripts
}
}
}Note that our builds evolved to be hierarchical, allowing us to only specify one environment per build, but it's not necessary. A build may contain multiple completely separate environments.
To implement runtime permeability, we use a new builtin function HH\environment_available, which takes a string representing an environment name as defined above. If the environment is included in the current build, it returns true, or false otherwise. The typechecker understands this function and will include the listed environment for the duration of the block. It follows that the argument passed to the function must be a string literal.
// Example: in some prod file:
if (HH\environment_available('Intern')) {
// Access intern code here...
}Or, we’re refining with some other mechanism (for example, being in a script implies that the Scripts environment is available), then we can assert an environment is available with invariant to provide a better error message.
// Example: in some prod file:
if (Environment::isScript()) {
invariant(
HH\environment_available('Scripts'),
'Being in a script implies that Scripts is available',
);
// Access scripts code here...
}Prior Art
[Cargo Workspaces](https://doc.rust-lang.org/book/ch14-03-cargo-workspaces.html) (Rust)
A Cargo workspace is a set of packages that share the same Cargo.lock and output directory. Packages within a workspace may depend on each other, and depend on the same set of external dependencies.
This is similar to this proposal in which the repo is the workspace, and each “package” is an environment. At Facebook, there is no lock file, but externally, a repo would have just one composer.lock, and each environment in the repo would depend on the same external dependencies.
One key difference is that there isn’t a notion of transitive dependencies (there was at some point, but it was a bug); dependencies must be declared explicitly:
The top-level Cargo.lock now contains information about the dependency of
add-oneonrand. However, even thoughrandis used somewhere in the workspace, we can’t use it in other crates in the workspace unless we addrandto their Cargo.toml files as well.
[Yarn Workspaces](https://yarnpkg.com/lang/en/docs/workspaces/) (JS)
The nomenclature is different here, but the concepts are similar to Cargo workspaces. A Yarn workspace is what Cargo would call a package (or we’d call an environment), but otherwise works similarly for interdependencies and external dependencies:
Requiring
workspace-afrom a file located inworkspace-bwill now use the exact code currently located inside your project rather than what is published on npm, and thecross-envpackage has been correctly deduped and put at the root of your project to be used by bothworkspace-aandworkspace-b.
Some key differences between Yarn workspaces and this proposal include:
- “Workspaces must be descendants of the workspace root in terms of folder hierarchy. You cannot and must not reference a workspace that is located outside of this filesystem hierarchy.”
- In our proposal, the environments could be anywhere, as long as both the typechecker and the autoloader know about them.
- “Nested workspaces are not supported at this time.”
- Our proposal does support nested environments, with the restriction that a source file may only be in exactly one environment.
- “Be careful when publishing packages in a workspace. If you are preparing your next release and you decided to use a new dependency but forgot to declare it in the
package.jsonfile, your tests might still pass locally if another package already downloaded that dependency into the workspace root.”- This is more than a transitive dependency problem. This means if any other workspace in the project has downloaded an external dependency, the current workspace can import it (even if it doesn’t depend on one of those other workspaces). This is a limitation of the package resolution algorithm. Our proposal requires dependencies to be either explicitly declared transitive or imported in each environment, and this will be enforced both by the typechecker and runtime.
[Assemblies](https://docs.microsoft.com/en-us/dotnet/standard/assembly/index) (C#)
You can think of an assembly as a collection of types and resources that form a logical unit of functionality and are built to work together. In .NET Core and .NET Framework, an assembly can be built from one or more source code files. In .NET Framework, assemblies can contain one or more modules.
Assemblies are analogous to environments in our proposal, and modules would be analogous to packages, which we may design in the future.
One key difference between assemblies and our proposal is the notion of friend assemblies: a friend assembly is an assembly that can access another assembly's internal (in C# or Friend in Visual Basic) types and members. For V1 of this proposal, we have omitted environment-level visibility and punted until we choose to design a packages feature.
[MODULES](https://blog.golang.org/using-go-modules) (Go)
Probably something to look into as well, because:
- it’s Google scale
- fairly new (mid-2019)
- there were mixed opinions around vendoring & dependencies before
Design Meeting Notes
Discussion with Dwayne (9/10)
- Permeability needs to be fleshed out more
- There appear to be two kinds of permeability
- 1: Explicitly introspecting on the current build to see which environments are available
- 2: Certain files simply being included in the prod build, seems arbitrary. The proposal kind of hand-waves these away by saying we’ll use the typechecker to move them to the right environment. But why haven’t we done that already? Is there a deeper reason?
- Is it intended to be a permanent feature?
- If so, it should be designed like a real language feature, not a special function with a string. It should support autocomplete, jump-to-definition, etc.
- If not, we need to deeply understand the current use-cases and discuss a 100% migration story. Possibly post in Intern/Prod Boundary Working Group to gain more specific use cases to discuss
- Needs more code examples
- How to break up switch cases that match on symbols within and outside of the current environment?
- There appear to be two kinds of permeability
- How does this fit into our dependency tracking model?
- What if environment files change? Must we re-typecheck that environment and all of its dependencies?
- What if a file moves between environments?
- How will this feature affect saved state (e.g. will it increase the size on disk)?
- Since we intend for this feature to be used for OSS libraries, how does this feature interact with package managers?
Brainstorm 3 (8/19)
- Scala packages appear to be pretty close to Hack namespaces already
- note the “agnostic to file layout”: https://docs.scala-lang.org/tour/packages-and-imports.html
- If code is not declared in a package, it’s in the empty package (e.g. root namespace in Hack)
- But does Scala have the problem of people injecting their code into packages?
- Idea: Environments define which namespaces(packages) can be defined within them. If code inside another environment declares that namespace(package), raise a typechecker error
- Entering a permeability block does NOT change the current environment, it just gives access to another environment
- This distinction is important when we have non-transitive dependencies
- TODO: Go back and revisit assumptions that may have changed due to existence of non-transitive dependencies
- Also explicitly list use-cases for non-transitive dependencies
- Tests
- Problem 1: We never want code in intern/prod etc. to depend on code in tests (environments problem)
- Problem 2: We want test code to have access to namespace internals (namespaces problem
Brainstorm 2 (8/05)
- We should find a way to model the needs of libraries with environments as well
- Fundamentally, namespaces can be declared everywhere, so they do not fit well into notion of “packages”
- Could namespaces be converted to a “packages” feature?
- Scala packages appear to be pretty close to Hack namespaces already; note the “agnostic to file layout”: https://docs.scala-lang.org/tour/packages-and-imports.html
- If code is not declared in a package, it’s in the empty package (e.g. root namespace in Hack)
- Transitivity should be configurable (transitive by default). Or, ability to seal environments
- Could have an internal (or export) keyword to control access to declarations within an environment
- Fundamentally, namespaces can be declared everywhere, so they do not fit well into notion of “packages”
- Tests: might need some notion of “Friend” environment
- Could replace <<__TestsBypassVisibility>> attribute
- Decision: not ready for design review
- Want to have an explicit plan on how to support libraries
- What about method-level granularity?
- Should this replace namespaces?
- Want to have an explicit plan on how to support libraries
Brainstorm 1 (7/29)
- Research other languages
- Scala has a relatively fine-grained visibility system
- Research Rust crates
- JS (package managers like Yarn)
- ✅ How to enforce environments at runtime in sandbox mode?
- Could you construct builds at runtime?
- Sandbox enforcement should be identical to production environment
- Will need to integrate with autoloader and facts
- Symbols would need to track the environment that they’re in
- That may be an argument against is expressions, because introspecting on the current build would change this runtime enforcement!
- ✅ Are Hack classes/interfaces the right DSL to model environments? Are they markedly better than just regular config files (e.g. package.json, etc.)?
- If we decide Hack syntax is not appropriate, what’s the alternative? HDF? TOML?
- Maybe this is a question for design review.
- Resolution: split proposal into abstract and concrete, will discuss syntax at design review.
- ✅ Do we REALLY need to centralize tests? Is there a middle ground between a first class feature and just regexes on file paths?
- ✅ The idea of hiding framework internals seems more like a language-level module problem (i.e. written in syntax, potentially via improved namespace support), while the problem at hand appears more to be a package management problem. Reflect that in the problem statement.
Hphp Hack Doc HIPs Implicit Pessimisation (`hphp/hack/doc/HIPs/implicit_pessimisation.md`)
HIP: Implicit pessimisation
Motivation
The existence of HH_FIXME and use of unsound dynamic in a large Hack codebase
precludes us from statically determining which values are used dynamically, and
which classes and functions may safely opt out. We would like to start from a
position that every value in the codebase supports safe dynamic operations. This
makes it safe for a value to fall into the blast radius of an HH_FIXME, because
it may simply be replaced with an upcast to an appropriate like type instead
using HH\FIXME\UNSAFE_CAST, and flow unimpeded into functions that support
dynamic calls.
Proposal
We propose a uniform pessimisation strategy where every class and function in
the codebase is implicitly marked <<__SupportDynamicType>>. To meet the
conditions for supporting dynamic, we implicitly interpret as follows:
All function and method returns of unenforced types become like types.
type D<T> = T;
class C {
public function good(): int {
return 4;
}
public function bad(): D<int> {
/* HH_FIXME[4110] */
return "hello";
}
}
function test(): void {
$c = new C();
$a = $c->good(); // $a: int
$b = $c->bad(); // $b: ~int
}Enums get partial enforcement at their base types to prevent override errors.
enum Transparent: int as int { A = 1; }
enum Opaque: int { B = 2; }
class C {
public function f(): int {
return 4;
}
public function g(): arraykey {
return 4;
}
}
class D extends C {
public function f(): Transparent /* ~Transparent & int */ {
return Transparent::A;
}
public function g(): Opaque /* ~Opaque & arraykey */ {
return Opaque::B;
}
}Add supportdyn bounds on abstract type constants
abstract class A {
abstract const type T /* as supportdyn<mixed> */;
public function f(): this::T { ... };
}Interpret HH\FIXME\UNSAFE_CAST<T1, T2>(e) as e upcast ~T1.
public function bad(): this::T /* like type */ {
return HH\FIXME\UNSAFE_CAST<string, int>("hello"); // returning ~int
}Interpret mixed, nonnull, function types, and open shape types as wrapped
in supportdyn.
function d(dynamic $d): void {}
function m(mixed $m): void {
// $m: supportdyn<mixed>
d($m); // implicit upcast to dynamic
}Weaken returns and inouts in function type hints
function f((function(): string) $f): void {
// $f: supportdyn<(function(): ~string)>
}
function g((function(inout string): void) $g): void {
// $f: supportdyn<(function(inout ~string): ~void)>
}The
~voidis unfortunate, but is addressed by thereturn await f();
proposal.
Interpret interface and abstract method returns as like types, as they have no
function body to trigger enforcement.
interface I {
public function f(): int;
}
abstract class A {
public function g(): int;
}
class C extends A implements I {
const type T = int;
public function f(): this::T { ... } // ~this::T overrides ~int
public function g(): this::T { ... }
}TODO: @sowens for the complex extends / implements case.
Hierarchy Poisoning
Interpreting unenforced types as like types causes problems in hierarchies.
class A {
public function f(): int { ... }
}
class B {
const type T = int;
public function f(): this::T { ... } /* override error */
}We resolve this by manually weakening the type of the parent class ahead of time
to prepare the codebase for implicit pessimisation
class A {
public function f(): ~int { ... }
}We introduce a handful of marker types to aid this preparation
| Marker name | Current interpretation | Sound dynamic interpretation |
|---|---|---|
POISON_MARKER<T> |
T |
~T |
TANY_MARKER<T> |
_ |
T |
SUPPORTDYN_MARKER<T> |
T |
supportdyn<T> |
Future
<<__NoAutoDynamic>>
Implicit pessimisation locks all classes and functions into supporting dynamic.
In order to migrate away from dynamism, we propose an opt-out mechanism <<__NoAutoDynamic>> that can be used for functions that are not dynamically
callable, and classes in the future once they are similarly verified. The
attribute turns off implicit pessimisation for the attached definition. The
definitions
class C {}
<<__NoAutoDynamic, __SupportDynamicType>>
class D extends C {}under implicit pessimisation are equivalent to
<<__SupportDynamicType>>
class C {}
<<__SupportDynamicType>>
class D extends C {}without implicit pessimisation.
Elimination of implicit pessimisation
The core goal of this project is to make explicit the use of dynamism in the
codebase by eliminating misfeatures such as HH_FIXME. Once a sufficient number
of classes are marked up with <<__NoAutoDynamic>>, we would like to "flip the
switch" and turn off implicit pessimisation. This will require explicit
insertion of <<__SupportDynamicType>> and like type hints. Then, any new
classes and functions will be outside dynamic by default, and users can reap the
benefits of static typing.
Decision points
- (copy paste) Which parts of sound dynamic will we ship?
~Tvs.HH\FIXME\POISON_MARKER<T>supportdyn<T>vs.HH\FIXME\SUPPORTDYN_MARKER<T>e upcast Tvs.HH\FIXME\UNSAFE_CAST<_, T>(like types only)- naming of
<<__SupportDynamicType>>
- Hierarchy overrides in the context of implicit pessimisation
Hphp Hack Doc HIPs Int Intish Shape Keys (`hphp/hack/doc/HIPs/int_intish_shape_keys.md`)
Feature Name: int/intish shape keys
Start Date: 2020-06-29
Status: Draft
Summary:
Shape keys are currently required to be:
- string literals that do not start with an int
- class constants
This proposal is to permit any arraykey literal, in addition to class
constants. More specifically:
- permit int literal shape keys
- permit any string literal shape key, even if they would be converted to ints
by PHP
Feature motivation:
- The ban on int-like shape keys is a PHPIsm in disguise: this exists because
of array-key-cast behavior which no longer exists in hack.- The ban on actual int shape keys was to avoid any confusion with int-like
keys, however there are other reasons (e.g. avoiding confusion with tuples)
- The ban on actual int shape keys was to avoid any confusion with int-like
- The bans are not actually consistently enforced, due to permitting type constants
- HSL Regex is already special and creates shapes with integer keys. It would
be useful to be able to type regexp patterns and matches fully as they're
passed around - This would remove some special-casing from the typechecker
User experience:
Generally as already-present for Regex\Match; additionally, this will now be
possible:
// Takes any regex, returns the entire matched string
function helper<T as Regex\Pattern(shape(0 => string, ...))>(
T $pattern,
): string {
return Regex\Match($pattern);
}Currently, it can only be typed as Regex\Match, which is declared asshape(...) and it is impossible to refine it or redeclare it to say that0 (entire string) or any specific numbered captures are present.
IDE experience:
Already present for Regex\Match; no change expected.
Implementation details:
- How the feature will interact with other Hack features
- will make HSL Regex more consistent; other interactions as already applying
- How the feature will be implemented
- removal of restrictions/special-casing
- A description of corner cases
- existing problems with class constants
- Any changes to HHVM
- none
- If applicable, strategies for codemodding
- n/a
Design rationale and alternatives:
Largely covered by 'motivation' above. No alternatives have been considered
for removal of ban on 'int-like' string keys. These are a PHPism.
Alternatives for the Regexp problem
The rest of this section addresses alternatives for actual int shape keys.
Using int-like string keys positional captures
For example, $shape['0']. The main problem is potential future issues: while
PCRE currently bans named capture groups that start with a number, the syntax
for referencing them differs; it appears that it would be possible to remove
this restriction without breaking compatibility, unless we introduce this
syntax.
This would also have a minor drawback from usability/familiarity benefit, as it
would be different to the representations in all other languages.
Using objects with getters
e.g. ->getNameCapture(string $name), ->getPositionalCapture(int $idx)
This is the approach taken by most other languages/libraries that support named
captures.
This would remove the need for any changes to shapes and tuples, however to
maintain the same static safety that we currently have (i.e. we know which
named and positional captures are valid), these objects will in turn need
to be special-cased - for example, perhaps re"/(foo(?baz))/" is inferred
to be a RegexpPattern<tuple(string, string), shape('bar' => string)> - however,
if tuples are used as part of the generic, changes will be needed to support
subtyping.
Tuples
These natively support sequential integer keys, however they have several
drawbacks here:
- it's valid to mix named and positional captures (and common, especially for 0);
in practice, would likely return both a tuple and shape, especially from some APIs - no sub-typing; in particular, most requests for specifying a type are for "I want
to take any regexp with at least these captures" - for example:- whole string, and a specific named capture:
shape(0 => string, 'foo' => string, ...)- Perhaps this would be
((string, ...), shape('foo' => string, ...)
- Perhaps this would be
- at least one position capture:
shape(1 => string, ...)- perhaps
((string, string, ...), shape(...))
- perhaps
- whole string, and a specific named capture:
Combined with the fact that all elements are the same type, this problem feels
like it would be better solved by bounded-size vecs - i.e. a vec<string> with
at least n elements - however, in the regexp case, the user normally cares
about presence of a specific n, not 0..=n, which is a problem already addressed
by shapes.
Drawbacks:
- may be misused when tuples are a better fit
- can not be converted to be class-based in the future
- unlikely due to existing COW semantics
- likely already impractical due to class constant hole and lack of runtime enforcement
- can not be converted to 'data classes' (a.k.a. 'records') in the future
- likely already impractical due to class constant hole and lack of runtime enforcement
Unresolved questions:
Future possibilities:
- remove class constant support
Hphp Hack Doc HIPs Memoization Options (`hphp/hack/doc/HIPs/memoization_options.md`)
Memoization options
This document describes proposed changes to the already defined Implicit Context feature in pursuit of the following goals:
- Expedite wide-scale usage of Implicit Context (IC)
- Ensure IC is always properly accounted for in memoization
- Reduce risk of breaking runtime assertions as part of initial migration or normal development
- Reduce risk of breaking existing logic depending on behavior of
<<__Memoize>>today
This feature is also referred to as the "Dynamically Enforced Implicit Context" feature.
Today: IC requires Contexts and Capabilities
The Implicit Context feature provides a value that is implicitly propagated to callees recursively. For example:
// Native, in HH namespace
abstract class ImplicitContext {
abstract const type T as nonnull;
public static function set<Tout>(
this::T $context,
(function ()[zoned]: Tout) $f
)[zoned]: Tout { ... }
public static function get()[zoned]: ?this::T;
}
// Userland
final class MyPolicyImplicitContext extends HH\ImplicitContext {
...
}MyPolicyImplicitContext::set(
$my_policy,
()[zoned] ==> do_stuff(),
);
...
function some_recursive_callee_of_do_stuff()[zoned]: void {
$_ = MyPolicyImplicitContext::get(); // returns $my_policy
}In order to prevent poisoning memoization caches (fetching the cached results computed under one IC value when executing under a different IC value), we designed the IC feature to be coupled with the coeffects feature which applies static recursive restrictions. We required that:
- You can only set an IC for the execution of functions requiring at most
[zoned]but we ignore this context in this document) - Memoized functions that are callable from a
[zoned]function must either:- shard the memoization cache based on the IC value (iff the context of the function is
[zoned]) - guarantee that the IC is not used by having a context without the
ImplicitPolicycapability which is required to access the IC (e.g.[leak_safe])
- shard the memoization cache based on the IC value (iff the context of the function is
While tying the IC to contexts and capabilities gives us static guarantees about code, adding these more restrictive contexts to code requires a lot of effort, and some developers want the benefits of the IC without the requirement of using contexts.
Proposal
We propose allowing setting an IC when executing functions requiring [defaults]. The new signature for this method on the ImplicitContext:: class will be:
public static function runWith<Tout>(
this::T $context,
(function ()[_]: Tout) $f
)[ctx $f, zoned]: Tout { ... }In the above, we have also renamed
ImplicitContext::settoImplicitContet::runWithin order to avoid an incorrect assumption that the IC is set to the given value after the completion of this method call. We use the new “runWith” name for the remainder of this document.
We will avoid poisoning memoization caches by dynamically requiring that memoized functions executing under an IC fall into one of two safe categories:
- The function is marked as having its memoization cache key incorporate the IC. This is achieved by using the
__Memoize(#KeyedByIC)attribute (formerly known as__PolicyShardedMemoize) instead of regular__Memoize. We will allow this attribute to be used with any function with theImplicitPolicycapability including[defaults]functions. - The functions is marked as never using the IC. This can be achieved either by:
- Using the new attribute
__Memoize(#MakeICInaccessible)instead of regular__Memoize. This attribute will act like__Memoizeexcept: attempting to fetch the IC or calling an uncategorized memoized function from a function with this attribute will throw. This behavior applies to immediate and recursive callees until an IC value is set again (if ever). - Using regular
__Memoizeand requiring a context that does not have theImplicitPolicycapability. Because fetching the IC requires[zoned], we know that recursive callees cannot access the IC. (Given the current set of contexts, this is the set of contexts as capable as or less capable than[leak_safe, globals].)
- Using the new attribute
- If a function is marked with
__Memoizewithout an categorization argument or coeffect, it will be treated as__Memoize(#MakeICInaccessible).
In the above, we are adding an optional enum class label argument to the
__Memoizeattribute. See the “Syntax” section.
Note: In this document, we describe semantics in terms of __Memoize and __Memoize(). The same statements apply to __MemoizeLSB and new __MemoizeLSB().
Description of states
Under this proposal, the IC can be in one of three states:
null : The IC has not been set
value(T) : The IC has been set to some value
inaccessible : Fetching the IC or calling uncategorized memoized functions will result in an exceptionDescription of state transitions
The following actions will have the following behavior:
Calling a function with regular `__Memoize` that has the `ImplicitPolicy` capability (e.g. `[defaults]`)
This is treated the same as __Memoize(#MakeICInaccessible)
* : Run, do not key cache with IC, transition to inaccessible state with this function as blameCalling a function with `__Memoize(#KeyedByIC)`
This does not affect the state of the IC.
null: Run and use null as the IC key
value: Run and use the IC value as the IC key
inaccessible: Run and use the inaccessible state (singleton value) as the IC keyCalling a function with `__Memoize(#MakeICInaccessible)`
* : Run, do not key cache with IC, transition to inaccessible state with this function as blameCalling a function with `__Memoize` that does not have the `ImplicitPolicy` capability
This does not affect the state of the IC.
* : Run, do not key with ICCalling `ImplicitContext::get`
This does not affect the state of the IC.
null: return null
value: return value
inaccessible: throwCalling `ImplicitContext::isInaccessible`
This does not affect the state of the IC.
null: return false
value: return false
inaccessible: trueCalling `ImplicitContext::runWith`
* : set the IC to a value, transition to the value state with this function call as blameCalling `HH\Coeffects\backdoor` or aliases
These backdoors allow calling code requiring [defaults] from various, less-capable contexts.
* : move to null state (clears any IC value)Calling `HH\Coeffects\fb\backdoor_to_globals_leak_safe__DO_NOT_USE`
This backdoor allows calling code requiring at most [leak_safe, globals] from any context.
* : no op, no state changeNote that by enforcing that the code executed via this backdoor can only require at most [globals, leak_safe], we prevent problematic calls to unsafe memoized functions and calls to fetch the IC.
Attributes’ interactions with coeffects
This section describes rules for what functions with what coeffects can use these new attributes.
__Memoize(#KeyedByIC)will only be permitted on functions that are known to have theImplicitPolicycapability at compile time.- This means that the functions’ context list must be implicitly
[defaults]or contain one of the following contexts explicitly:defaults,zoned,zoned_shallow,zoned_local. - Reason: A developer should be able to expect that a function without the
ImplicitPolicycapability is not affected by the Implicit Context. However, this would not be the case if we allowed#KeyedByIC— e.g. imagine a memoized, leak-safe function that returns a random number.
- This means that the functions’ context list must be implicitly
__Memoize(#MakeICInaccessible)will only be allowed on functions with any of the following contexts:defaults,leak_safe_shallow,leak_safe_local- Reason: For functions without the
ImplicitPolicycapability, the IC is already inaccessible and the function can use regular__Memoize. For functions withzoned, using this attribute seems contradictory. - This will be similarly enforced at compile time.
- Reason: For functions without the
- (As before) A memoized function with the
zonedcontext must use#KeyedByIC. - (As before) A function with dependent contexts may not be memoized.
Expected migration path
A developer that wants to enable adopt DEIC in a codebase:
- At the start of request, begin executing the code using
ImplicitContext::runWith - Add calls to
ImplicitContext::isInaccessiblewhere you expect to fetch the implicit context viaImplicitContext::getand log all violations. - Address these logs and remediate them as fit (ie. Change
__Memoizefunctions to be#KeyedByIC). - Once violations are sufficiently addressed, start using
ImplicitContext::getto influence runtime.
Path to contexts and capabilities
Allowing use of a dynamically-enforced Implicit Context is compatible with a statically-enforced Implicit Context using Contexts and Capabilities. In fact, the work to specify IC-handling for memoized functions is a subset of the work required to make functions callable from [zoned] contexts.
In abstract, you can think of functions using __Memoize(#KeyedByIC) as being functions where the intention is to eventually require [zoned] (if it does not already) and think of functions using __Memoize(#MakeICInaccessible) as being functions where the intention is to eventually require [leak_safe] or some more restrictive context.
Syntax
The current proposal introduces an optional, enum class label argument to the existing __Memoize (and __MemoizeLSB) attributes e.g. __Memoize(#KeyedByIC). This would require adding the ability to use enum class labels in attribute argument positions.
enum class MemoizeOption: string {
string KeyedByIC = 'KeyedByIC';
string MakeICInaccessible = 'MakeICInaccessible';
}Currently, the __Memoize attribute is hard-coded into the typechecker and there is no declaration for it. However, you could declare it like so:
final class __Memoize implements HH\FunctionAttribute, HH\MethodAttribute {
public function __construct(
private ?HH\EnumClass\Label<MemoizeOption, string> $kind,
) {}
}Alternatives we considered:
- Using special constants e.g.
__Memoize(KeyedByIC). However, constants are not permitted as attribute arguments as attributes are evaluated at compile time and using evaluation constants would require “decls in compilation.” We also compute attributes in order to build indices for Facts which may never have access to cross-file declarations. - Using special strings e.g.
__Memoize("KeyedByIC")— this has the downside of a string argument appears to take arbitrary strings, but can only take specific, special strings. This may also require additional changes to support autocompleting these strings in IDEs. - Creating a new attribute to replace each variant e.g.
__MemoizeKeyedByICand__MemoizeMakeICInaccessible- If we choose this option, we may consider more fluid names like
__MemoizeWithICand__MemoizeAndMakeICInaccessible - This has the downside of adding each attribute for memoize and lsb variants, increasing the code complexity in parser/runtime
- If we choose this option, we may consider more fluid names like
- Creating auxiliary attributes to use alongside
__Memoizee.g.<<__Memoize, __MakeICInaccessible>>- This option has the downside of introducing attributes that can only be used with other attributes which would be especially undesirable for
__Memoize(KeyedByIC) - We could conceivably allow
__MakeICInaccessibleto be used standalone. However, at the moment, we do not see a legitimate use for it without an accompanying__Memoize.
- This option has the downside of introducing attributes that can only be used with other attributes which would be especially undesirable for
Naming
"KeyedByIC" uses "Keyed" which references the fact that the IC is incorporated into the key used in the memoization cache. We could also possibly use imperative "Key" vs past participle "Keyed." Possible alternatives to the verb "key:"
- "Shard" - original verbiage from "PolicyShardedMemoize" but noted possible incorrectly inferred association with database sharding
- "Split"
- "Partition"
"MakeICInaccessible" describes how the IC cannot be accessed. A previously proposed name used the verbiage "clear IC," however this created a distinction between a state where the IC was "cleared" vs one where the IC was never set. Primary concerns with this name are aesthetic: "inaccessible" is long and may be awkward to say and spell. A name like + "IC" may be preferable, but verbs like "prohibit" or "ban" may incorrectly imply immediate exceptional behavior of the function with the attribute instead of restrictions on the function's dependencies.
“IC” as an abbreviation for “Implicit Context” was chosen for brevity and lack of more attractive options. We concluded that brevity was valuable given expected prevalent usage of these attributes and that the disadvantage of possible ambiguity of “IC” would be offset by the fact that the full symbols names would be sufficiently unique. Other alternatives that were considered:
- “IArg” as in “Implicit Argument” — This primary issue with this option is that “implicit arguments” is a feature in other languages with a different meaning. Using this term would both set incorrect expectations for readers with experience in those languages as well as be problematic should we want Hack to adopt a similar feature.
- “ICtx” (using “ctx” as an alternative abbreviation for “context”) — The primary objection to this was that while we use “ctx” as an abbreviation for “context” in other features of the language, in those cases, “ctx” refers to a coeffects “context,” which, while there are some tie-ins, the “contexts” in each case are distinct concepts.
- “ImplicitContext” — This was considered too long, especially in
__Memoize(#SoftMakeImplicitContextInaccessible). - “Ctx” or “Context” (without “implicit”) — This was considered insufficiently disambiguated as “context” is already an overloaded name. A developer may reasonably, but incorrectly, assume that “KeyedByContext” would mean “keyed by the coeffects context.”
- Other names: “Implicit Value” or “IVal,” “ScopedValue,” “ContextValue” or “CVal” or “CtxVal”
Multiple Implicit Contexts
The current implementation of Implicit Contexts allows for multiple IC “flavors.” You create a flavor by defining a class implementing the native HH\ImplicitContext class. This presents the question for how the above features should work when there is more than one IC flavor.
We have identified a few options:
- Only allow a single flavor and require any further division of the value to be done in userland. This has the benefit of simplifying the semantics. This also inherently requires coupling among all flavors, but unless you parameterize
KeyedByICandMakeICInaccessiblefeatures by flavor, the flavors are already tightly coupled since they will need to agree on when these attributes ought to be used. Coupling is also introduced by sharing related coeffects and the backdoor functions. - Track an IC state per flavor and allow individual setting. Each flavor’s state could be set individual by
runWithcalls, butMakeICInaccesiblewould set all flavors’ state toinaccessible. This would likely require more checks at runtime resulting in worsened performance. - Only track flavors in the value state. Setting one flavor from the
inaccessiblestate would resulting in implicitly setting all other flavors’ value to the initial null state.
Our recommendation is that we take Option 3 which is closest to the current implementation of Implicit Contexts but require in userland that there is at most a single child of HH\ImplicitContext. This effectively means choosing Option 1 from the perspective of the user. This compromise allows us to make fewer changes to the runtime in the near term. We can choose to simplify the runtime to not support multiple flavors at a later date.
Hphp Hack Doc HIPs Named Parameters (`hphp/hack/doc/HIPs/named_parameters.md`)
Authors: Max Heiber, Sinan Cepel
Status: Accepted and Implemented
Internal context: https://fburl.com/hack_named_parameters_proposal
Named Parameters: HIP
Goals and Constraints {#goals-and-constraints}
Goals
Listed in descending priority order (we plan to deliver on both):
- G1: DevX for both humans and AI
- Why:
- Lack of named parameters makes WWW code hard to work with: users must manually deal with large parameter lists or with the awkwardness of simulating named parameters with shapes.
- Our ergonomics has fallen behind that of other languages
- Part of a broader effort to make WWW easier for both humans and AIs to work with (both reading code and writing code)
- See also Example of improved ergonomics for py2hack
- Why:
- G2: increase safety
- Why:
- Prevent SEVs and related issues (including runtime errors and unexpected behavior). Specific SEVs are in https://fburl.com/hack_named_parameters_proposal
- Support AI-generated code efforts through enabling more trust in tooling
- Why:
Hard Constraints
- C1: Easy to adopt. Implies:
- Ergonomics
- Codemoddability
- Converting a function and its call sites from simulating-named-params-using-shapes to real named parameters should be perf-positive
- C2: Feasible.
- We need to be able to deliver value in 2025
Defeasible Constraints:
- C3: Avoid prematurely restricting the design space
- We ship early but make it as straightforward as we can to add or change things later.
Non-goals
- Improve performance significantly
- Why: Improving on simulating-named-params-via-shape-passing is sufficient to meet our goals and the design space is already heavily constrained.
- Feature-completeness:
- Why: we can provide value and gain learnings earlier by shipping something that meets the goals but leaves open room for more features (see Potential improvements outside initial MVP scope below).
Solution Sketch {#solution-sketch}
- When a parameter is named, the call site must provide the corresponding argument name.
- Named parameters are distinct from positional parameters (parameters are not "named by default")
- Parameter names for named parameters are part of the function type.
Examples:
// definition
function foo(int $a, int $b = 0; int ...$args, named int $c, named int $d = 0): void {}
function main(): void {
foo(1, c=2);
foo(1, 3, d=2, c=2);
foo(1, 3, 4, c=2, d=2);
// hh error: too few arguments
// HHVM RuntimeException
foo();
// hh error: too few arguments
// HHVM RuntimeException
foo(1);
// hh error: unexpected named argument
// HHVM RuntimeException
foo(1, missing=2);
}Hierarchies:
class A {
public function m(named int $a): void {}
}
class B extends A {
// hh error: `m` must have a named parameter `$a` since it overrides `m` in class `A` which has named parameter `$a`
// Runtime error: `m`'s override declaration is inconsistent with base.
<<__Override>>
public function m(named int $b): void {}
}
function main(): void {
let $b: A = new B();
// HHVM RuntimeException
$b->m(a=1);}Anonymous functions and types:
Note: optional is an existing keyword in function types
function take((function(int, named int $b, named optional int $c)) $f): void {}
function main(): void {
$f = (int $a, named int $b, named int $c = 0) ==> $a + b + c;
$f(0, b=1);
$f(0, b=1, c=2);
take($f);
}User-facing Documentation {#user-facing-documentation}
You can declare parameters of a function as named, in which case callsites must specify the parameter by name instead of positionally.
function add_to_table(named int $row, named int $col): void {
// ....
}
.....
function main(): void {
add_to_table(row=1, col=2);
}In the example above, named parameters help callers avoid mixing up $row and $col:
`add_to_table(2, 1); // hh error: missing named parameters $row and $col`Named parameters can make your code safer and easier to read when a function has parameters that would otherwise be easily confused:
- multiple parameters of overlapping types (e.g. boolean flags, ?int and null)
- especially when such parameters have default arguments
Here’s an example with easily-confused parameters:
protected async function genMessageDelta(
Context $context,
ID $id,
Map<string, string> $data,
?arraykey $offline_id = null,
?vec<string> $sender_tags = null,
?vec<string> $meta_tags = null
)
.......
await genMessageDelta($ctx, $id, null, null, vec['tag1']);By making the easily-confused parameters named, calls to the function get both safer and easier to read:
protected async function genMessageDelta(
Context $context,
ID $id,
Map<string, string> $data,
named ?arraykey $offline_id = null,
named ?vec<string> $sender_tags = null,
named ?vec<string> $meta_tags = null
)
.......
await genMessageDelta($ctx, $id, meta_tags=vec['tag1']);Named parameters behave just like positional ones except for how you refer to them at callsites: named parameters can have default arguments, and are matched according to their names at call sites.
Note that args for named parameters can be interspersed with positional args :
function send(
string $message,
named string $host = 'www.thefacebook.com',
int $retries = 3 // default argument
) {
...
}
async function foo() {
send('poke', host='www.facebook.com');
// can permute order
send(host='www.facebook.com', 'poke');
}Historical remark
You may see older code that simulates named parameters using shapes:
protected async function genMessageDeltaShapeVersion(
Context $context,
ID $id,
Map<string, string> $data,
shape(
?'offline_id' => arraykey,
?'sender_tags' => vec<string>,
?'meta_tags' => vec<string>,
) $opts
) {
$offline_id = $opts['offline_id'];
$sender_tags = $opts['sender_tags'];
$meta_tags = $opts['meta_tags'];
// ...
}
.......
await genMessageDeltaShapeVersion($ctx, $id, shape('meta_tags' => vec['tag1']));Prefer using named parameters over simulating-named-parameters-via-shapes: Names parameters are:
- more ergonomic
- more performant
- safer: HHVM checks types for named parameters but not for individual keys in a shape in parameter position
Solution in Detail {#solution-in-detail}
Syntax
> Syntactic restrictions are enforced by both HHVM and Hack, which share a parser
> For syntax at a glance, see examples above
In declarations:
- Named parameters are indicated with the
namedkeyword. - Excerpt from how we will update the parser, the new part is indicated. (note that our current BNF-style comments in the parser are out of sync with the impl, so impl is more informative here):
- parameter_declaration before:
attrs_opt visibility_opt inout_opt readonly_opt ellipsis_opt <ellided> - parameter_declaration after :
attrs_opt visibility_opt inout_opt named_modifier_opt readonly_opt ellipsis_opt <ellided, no change> namedcan be used in constructors and is allowed with promoted properties:class A { function __constructor(public named int $x)
- parameter_declaration before:
- Named parameters can have default arguments: function foo(named int $x = 1): void {}
- Same restrictions (and freedoms) on default arguments for named parameters as for positional parameters
- Named parameters will initially not support inout for pragmatic reasons to do with the runtime. Such support may be added in future.
In call sites:
- Basic syntax:
foo(x=3)supplies argument3for named param$x - Positional arguments can be interspersed with named arguments:
bar(y=5, 1, 2, 3, x=4)
Named parameters in type syntax:
- Named parameters are indicated with the
namedkeyword, which comes beforeoptionaland before the type of a parameter.function take((function(int, named int $b, named optional int $c)) $f): void {} - Named parameters must have names.
Runtime Semantics and Checks
A complete description of the runtime behavior is in the Supplement: Runtime Design section below.
Basic representation
- The runtime will extend Func objects to store the names of named parameters, and store them internally in an implementation-defined order, distinguished from positional parameters.
- The calling convention will be extended such that:
- Named parameters are passed from the caller to the callee respecting the implementation-defined order of names, including an additional token containing the names of the parameters to deal with the case of dynamic and unknown calls.
- The callee will perform the various parameter name and type hint checks, detailed below, and populate default values as appropriate. A runtime exception will be thrown if a required name is missing or an argument name not matching a parameter is provided.
- Type hints and behavior named parameters will be consistent with positional parameters except for the additional name checks.
Order of argument evaluation
Arguments will be evaluated left to right (based on call site). Specifically, for a callsite foo(b=expr_b(), a=expr_a()) , expr_b() would be evaluated first even if the implementation-defined ordering would pass a before b to the callee.
Why:
- Likely best DevX (G1), users have an intuition of evaluation being left-to-right. Additionally, every other language we’ve checked evaluates args-for-named-params in left-to-right order (except OCaml, where order is not specified). See column D13 in the Supplement: Named Parameters Prior Art and Design Space section below.
- HackC doesn’t have access to decls, so ordering based on the parameter order in the declaration is not really on the table by C2 (feasibility constraint)
Callee-side runtime checks
Type checks
On the caller side, type checks for named parameters will be performed analogously to positional params. Type hints will be checked in the order of (1) positional parameters, (2) named parameters and (3) all reified types.
Type hint checks for positional and named parameters will be consistent in the checks performed, but for named parameters, the checks will not necessarily be in the user-declared order. Type hint error messages will be issued in an order consistent regardless of which order they were declared in.
Callsite arity & name checks
HHVM behavior for positional parameters is unchanged:
- HHVM will produce a
RuntimeExceptionwhen a required positional parameter is not provided. - HHVM will not produce an exception for extra parameters passed positionally. This is arguably a mistake, but we're not changing it here.
HHVM behavior for named parameters:
RuntimeExceptionfor not providing a named argument for a non-optional named parameter- Why:
- by G2 (safety goal). If we don’t do this check, it’ll be behavior changing for developers to add new named parameters.
- by C3 (avoid prematurely limiting the design space). If we don’t do this check, it will be harder for us to add shape-splatting for named parameters in future.
- Why:
RuntimeExceptionfor providing a named argument that doesn't correspond to any named parameter.- Why:
- by G2 (safety goal) we want it to be safe for API designers to add new named parameters in the future without breaking existing code
- by C3 (avoid prematurely limiting design space) we want to allow for potentially adding named variadics in future (on analogy with positional variadics, may resemble Python **kwargs).
- Why:
Hierarchy checks
We propose introducing override compatibility checks in the runtime. If a class attempts to override a method with a new method that doesn't provide all named parameters or overrides a named parameter with-default into one without, HHVM will throw a RuntimeError.
Named Parameters in Type Structures
Reified types for functions containing named parameters will include the name of the parameters in the type structure. Named parameters for function types in type structures will be laid out after all positional parameters in an implementation-defined order, regardless of the declaration order, and the name will be retained in the type structure.
\_\_Memoize, \_\_MemoizeLSB
Functions with named parameters will be memoizable. Memoization keys will depend on the set of parameter names, but will not be affected by the declaration ordering. Changing the order of named parameters in the declaration won’t change the memoization key.
hh
- Named parameters are included in types.
See also example above.
Note for Hack team: named parameters should be explainable in terms of a purely-theoretical-but-not-actual rewrite to code that passes shapes, for migratability and language feature composability. See also https://github.com/mheiber/lapes for a formalization.
Function subtyping rules follow from the rewriting semantics, an example is:
(function(arraykey, named arraykey $b, named optional arraykey $c))
<: (function(int , named int $b, named optional int $c))In addition to checking types:
- hh errors when a named argument is missing (when there is no named argument at the call site corresponding to a named parameter in the callee)
- hh errors on extra named arguments (named arguments that don’t have a corresponding named parameter)
- hh errors on incorrect overrides
- Note: names of named parameters are part of function types
In addition, hh will reflect the order of evaluation of arguments for:
- type refinement (such as refining from ?int to int)
- type invalidation (due to side effects)
Prior Art and More Design Justification {#prior-art-and-more-design-justification}
See the Supplement: Named Parameters Prior Art and Design Space section below.
Potential future work {#potential-future-work}
See the Supplement: Potential Future Work section below.
Supplement: Runtime Design
Main HIP: See the Named Parameters: HIP section above.
Runtime constraints
We plan on named parameter semantics to be explainable via elaboration-into-shapes. i.e.
// Declaration
foo(named int $x, named int $y = 2) { ... }
// Can be thought of as
foo(shape(‘x’ => int, ‘y’ => int) $s) { $x = $s[‘x’]; $y = $s[‘y’]; … }
// Use
foo(x:1, y:2);
// Elaborated to
foo(shape('x' => 1, 'y' => 2));
// Reordering is OK
foo(y:2, x:1);This elaboration into shape has some relevant consequences for us:
- In general, we won’t know the position of a named parameter but we need to be able to match it to the right param
- Positional parameters are treated distinctly from named parameters, i.e. named params are defined by their name rather than their position, and should not interact with positional parameters. As an example, it should be possible to interleave named parameters anywhere in the declaration without affecting how positional parameters or variadic parameters are laid out.
- We can’t rely on a consistent order for things like defaults in the general calling convention.
We have some additional checks we want to have for future-proofing:
- HHVM should throw a RuntimeException if an unexpected parameter is passed in.
- HHVM should throw a RuntimeException for a missing named parameter without a default.
- HHVM should throw a RuntimeException when overriding a base method with incompatible named parameters (missing a named parameter that exists in the base method, or lacking a default value for a named parameter that has a default in the base).
Representation changes
Functions have an existing shared ParamInfo object that’s already 88 bytes in non-lowptr: $REPO//hphp/runtime/vm/func.h
I propose adding a new “Named” flag to parameters (still fits in the uint8_t) and adding an extra LowStringPtrOrId field for the parameter name.
Calling convention with named parameters
See https://fburl.com/excalidraw/f2l4khnn for details.
Inout Named Parameters
We propose banning inout named parameters for the MVP. The problem they pose from a runtime perspective becomes clear when considering how regular inout parameters are handled:
For existing inout params, the caller specifies a count of inout parameters when calling, and creates extra cells for the right number of inout values being returned. The callee will RetM with the appropriate number of extra return values.
For named inout params, we have two choices:
- We could ignore the names, and rely on the consistent runtime ordering observed elsewhere (i.e. if you have
inout $aandinout $b, have a convention of always ordering the cell for$a’s value before$b’s cell), and throw a RuntimeException if the number of cells returned don’t match the cells pushed by the caller. This would be more performant, but have a safety downside: If a dynamic or unknown call has a separate set of names for the inout named parameters (with matching count), we’d be silently propagating incorrect values. - We could validate the returned names matching the inout names on the callee side. One potential design here would involve the caller placing a C-style array with the inout names before the inout cells (in addition to the set of all parameters), and the callee validating that structure in the prologue, and use the design of (a) for the rest of the checks. This would be a significant tax on calls with inout params, but that may be acceptable depending on the frequency of calls.
Memoize
Memoization will be handled transparently.
Initializers
We will initially not support initializers. There’s a couple options on how to support it moving forward:
- We can try directly inlining the bytecode for initializers/call the 86cinit functions in prologues.
- We can have a special bytecode section that keeps the bytecode needed to initialize each constant, have the prologue jump to those.
Splatting
We will have a separate unique stub for splatting.
Supplement: Potential Future Work
The following could be added after we release the features described in the Named Parameters: HIP section above.
These are a combination of:
- We have some nice-to-haves that can be added after we’ve addressed our main goals. These include likely-desirable features, but we’d like to focus on getting an extensible basic design first and relying on user feedback to prioritize features.
- Things in the design space we don’t want to prematurely close off but are not considering any time soon
This work includes:
- Tooling:
- lint+codemod requiring parameters to be named in cases that would otherwise be footgunny. We can prevent future SEVs (like those listed in Goals above) using a lint that nudges toward “use a named parameter here” at otherwise-footgunny function definitions..
- quickfix for making a parameter named
- Punning may make the DevX (goal G1) even better , but isn’t strictly necessary for better devX and safety, so we defer it.
- Enable shape splatting, similar to how we now allow splatting tuples into positional arguments.
- Named generic parameters: dict<Key:string,Value:int>
- Not much interaction with the current proposal, best considered in separate proposals imo, if it turns out there are use cases.
- See also the Supplement: Named Parameters Prior Art and Design Space section below: we indicate more future extensions and their advantages and disadvantages.
Supplement: Named Parameters Prior Art and Design Space
Column Key
- D1: When a parameter is named, giving the name at call sites is required or optional?
- D2: A child class can override a method and provide different names for its arguments
- D2.1: Type-checker checks by (note: subtle due to overridden methods)
- D2.2: Runtime checks (note: subtle due to overridden methods)
- D3: Can reorder named arguments at call site
- D4: Can define internal parameter name distinct from external parameter name
- D5: All parameters are "named" by default
- D6: Method ptr type includes param names for named params AND named params allowed in lambdas
- D7: Punning
- D8: Splatting
- D9: Parameter name available via facts API
- D10: Syntax
- D11: Restrictions on order of params
- D12: Named parameter version of variadics (python kwargs)
- D12b: Inout parameters
- D13: Evaluation order for arguments corresponding to named parameters
| Language | D1 | D2 | D2.1 | D2.2 | D3 | D4 | D5 | D6 | D7 | D8 | D9 | D10 | D11 | D12 | D12b | D13 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| disadvantages | - ergonomics (complicated, potentially unintuitive)- may lead to unsafety (unless we manage with restrictions) | (1) leads to inconsistency between call sites. (2) May make it less obvious to AIs and people how to use the function (3) reduces correspondence between the call site and the definition (which, fwiw, I find convenient and intuitive) | - noisy, verbose, and (imo) hard to read and remember- LLMs seem to have trouble figuring out how to correctly call functions that have separate internal and external parameter names | - requires codemodding the world, especially for overrides that change parameter names vs the parent- moves the language away from one way to do things | Multiple overlapping splats and potential splat/explicit naming overlaps are a footgun in other languages that contradicts G1 | n/a | ||||||||||
| advantages | - adoptability for existing code- refactorability- more specialized names may make more sense in subclasses | By C3 (avoid prematurely restricting design space): adding checks before a feature is adopted is way easier than adding after. We can always loosen restrictions later if we need to.By C1 (DevX): HHVM failing fast rather than doing something weird imo helps users pinpoint problems faster. | - Andrew Kennedy has pointed out that it would be nice to explain the semantics in terms of shapes (even though we won't implement that way). Advantages I see is that such a "desugaring semantics" pushes toward consistency, make it more likely that we are set up well for splatting to work sensibly, and will make it more likely that we can accomodate existing use cases for shape-passing (providing a better alternative).- Given that we include names for named parameters in function types, allowing reordering makes it more things compatible. It could be annoying and wasteful to have to write adaptor functions to convert from a function (named int x, named int y) to (named int y, named int x)` or to duplicate interfaces. |
Note: It's always possible to simulate this capability using local variables.- can help devs avoid lints for unused parameters in function definitions. The workaround on the current proposal is function foo(named int $x): void { $_ = $x; }. - can make it easier to support method overriding s.t. names have to match: we could make it so only the external name needs to match.- Gives a way to access things in an outer scope without changing the outer scope. Example with internal-vs-external-names P1881417913 and a workaround: P1881416688 |
Enables callers to opt in to more safety for parameters not explicitly marked as named | - reduces boilerplate, sometimes making Hack code easier to write and read | Makes taming larger signatures easier, helps G2 | Makes it easier (possible?) for codemods to generate correct code | byG1 (DevX for AI and for humans):in particular it seems to be easier for AI to generate code when features compose and stuff just works | |||||||
| reversible/relaxable decision | Name is required | No | No (would be a big change) | No (would be a big change) | Yes | No | No | Yes | No | No | Yes, helps with G2 | Yes | Yes | ? | ||
| our recommendation. | Yes, by G2. | No for MVP, by G1, C2, maybe G2 | static type. Which will match the runtime type, due to the decision in previous column (no overriding and changing name).hh will reject:- not providing a named argument for a non-optional named parameter- providing a named argument that doesn't correspond to any named parameter- overriding a method incorrectly (remember that the names of named parameters are included in function types) | by method declared in runtime class (following the same rules of dynamic dispatch in Hack. Note that this is distinct from languages like Scala where static class determines which names are used for named parameters).HHVM will reject:- not providing a named argument for a non-optional named parameter- providing a named argument that doesn't correspond to any named parameter- overriding a method incorrectly (remember that the names of named parameters are included in function types) | Yes | No for MVP, by G1. In experiments with LLMs, they were unable to infer how to call a function correctly when it had different internal-vs-external params (and, fwiw, I often forgot the syntax for separating internal-vs-external names in OCaml and Objective-C) | No for MVP by C1 and C2.Unlikely for follow-on work, since it would be confusing to have both named-or-positional args and named args. Python has both kinds (actually, at least three kinds) but in a way that is unsafe | Yes, needed for typed mocking. We want to support typed mocking by G2 (safety) even though this makes implementation harder (tension with C1).See Named Parameters: Goals and Constraints | No for MVP, not needed for G1 or G2 and in slight tension with C1 (C1 implies codemoddability, but punning introduces a minor+mitigatable refactoring hazard) | No for MVP, by feasibility constraint C2 | No for MVP, by feasibility constraint C2 saving for follow-on | provisional syntax:<br>function foo(named int $x): void {} <br>...<br>foo(x:$x) // PHP syntax<br>Syntax chosen because LLMs predicted the call syntax correctly given the decl syntax and an LLM recommended this named keyword solution. |
intuition: positional before namedrestriction:positional+required, followed by positional+optional, positional+variadic, followed by namedNote: we need to relax the current rule that variadic has to come lastAllowed:function foo(int $a, int $b = 1, named int $c, named int $d = 1)Disallowedfunction foo(int $a, int $b = 1, named int $c = 1, named int $d)function foo(int $b = 1, named int $c = 1, named int $d, int $a).....Allowed but counterintuitive:function foo(named int $x = 0, named int $y = 1) {}function bar() { foo(y:2); }Why:By C3 we do not want to block future features. This seems to me to leave things maximally open, including enabling reordering arguments (D3), maybe allowing named-args-everywhere, fast-calling-convention under the hood, etc. | No for MVP by feasibility constraint C2.Two potential follow-ons:- enable splatting shapes - Note: for consistency and to avoid bad incentives, we would probably then also allow reordering of named arguments, since shape types do not specify order - Note: unsure of performance implications here- enable splatting named tuples - Note: would require named tuples to exist in HackUse cases may include Ents needs. Currently they use splatting along with enum classes and variadics to avoid code bloat. | Will support if there's an efficient way to do so in the runtime | |
| Scala Scala specScala example 1 | Optional | Yes | Parameter name in the class given by the static type example.In Scala, since overriding methods can have default values, arguments at the same position can match with distinct parameters (by position) when overriding (example). Seems like a safety hazard. We could address the hazard by not allowing such ambiguous-looking cases, but this does require propagating a lot of information. A way of partially addressing is to disallow permutation. | None (90% sure) | Yes, with restrictions (D11) | No | Yes | ? | ? | left-to-right by call site: https://scastie.scala-lang.org/sM75rKoBQ3eEno3wLQY32Q | ||||||
| Kotlin Kotlin SpecKotlin example 1 | Optional | Yes | Parameter name in the class given by the static type: exampleIn Kotlin, an overriding method cannot have default arguments (example). This avoids ambiguities but is counter to how Hack works and could hinder adoptability. | None (90% sure) | Yes, with restrictions (D11) | No | Yes | ? | ? | left-to-right by call site: https://pl.kotl.in/4bCPiojcz | ||||||
| SwiftSwift referenceSwift example | Required | n/a, see next column | n/a, since parameter name is part of the method name. Also: Can get a compiler error by using ‘override’ keyword in a function definition with the same signature except for parameter name. Also Swift is Yes for D4 (internal-vs-external names) | n/a, since parameter name is part of the method name | Noexample | Yes | Yes | ? | ? | n/a | left-to-right by call site | |||||
| C#C# specC# example | Optional | Yes | Name in the overridden method (even if the static type is from the parent class) | ? | Yes, with restrictions (D11) | No | Yes? | ? | ? | n/a | TODO | left-to-right by call site | ||||
| Python (mypy), but note that mypy probably violates the specCanonical references for typing:- Typing spec- Typing conformance suite | Optional unless defined as “keyword-only” | Yes | By static type (unsound!) | By runtime type | Yes, with restrictions (D11) | No | Yes, unless defined as positional-only | Yes, optionally (80% sure) | No | n/a | TODO | Yesdef foo(a: int, b: int): ...kwargs = {'a': 1, 'b': 2}foo(**kwargs) | left-to-right by call site | |||
| Python (pyre) | n/a | same as cell above | same as cell above | |||||||||||||
| Python (pyright) | n/a | same as cell above | same as cell above | |||||||||||||
| RubyRuby docsRuby example | Required | Yes | n/a | By runtime type | Yes, with restrictions (D11) | No | Yes | n/a | No | n/a | TODO | |||||
| OcamlOCaml manualOCaml exampleOCaml syntax cheat sheet | Required (when setting the warning to be an error, which in practice I think most professional codebases do) | No | n/a: overridden method must have same names for named parameters | n/a, not checked at runtime (95% sure) | Yes, with restrictions (D11) | YesExample:let add_1 ~x:y = y + 100 - external name is x - internal name is y |
No | Yes | Yes | n/a | TODO | unspecified |
Hphp Hack Doc HIPs Nameof (`hphp/hack/doc/HIPs/nameof.md`)
HIP: `nameof`
Summary
A new way to get class names as strings: nameof C.
NOTE: C is a stand-in for an
Aast.class_id, excludingAast.CIexpr.
Feature motivation
This feature is meant to replace the use of C::class expressions in
string positions that trigger an implicit cast, so the implicit behavior can be
eliminated. Typing mimics C::class to maintain types during migration,
while compilation mimics (string)C::class to model existing implicit string coercion.
The feature also supports the migration of places where package boundaries
are violated, so that C::class can be changed to return a validated class
pointer. We will then ban and eliminate implicit and explicit (string)
conversion in the runtime.
Specification
The expressions valid on the left hand side of C::class are
- Classes
- Interfaces
- Traits
- Enums / enum classes
static,self, andparent- Type aliases
- Reified generics in scope
nameof supports 1-6 as targets. The confusion around 7 was significant enough
to where we've decided to exclude it. Other top-level identifiers
are similarly unsupported by nameof at the moment, such as nameof my_function,nameof MY_CONST and nameof my.module, and Hack will error as though they
are missing (or illegal in the case of my.module) class names.
Let's proceed by example. Notation is id: type = bytecode.
Class
class B extends A {
public function f(): void {
$b = nameof B; // $b: classname<B> = String "B"
$s = nameof self; // $s: classname<B> = String "B"
$p = nameof parent; // $b: classname<A> = String "A"
$stat = nameof static; // $stat: classname<this> = LateBoundCls; ClassName
}
}Parent undefined
class B {
public function f(): void {
$b = nameof parent; // $b: classname<nothing> (Typing[4076]) = ParentCls; ClassName
}
}Interface
interface I extends I2 {
const string S = nameof I; // S = """l:1:\"I\";""";
const string T = nameof self; // T = """l:1:\"I\";""";
// matches parent::class and static::class behavior
const string U = nameof parent; // Parsing[1002] invalid constexpr
const string V = nameof static; // Parsing[1002] invalid constexpr
}Interface require extends (same as previous)
interface I {
require extends I2;
const string S = nameof I; // S = """l:1:\"I\";""";
const string T = nameof self; // T = """l:1:\"I\";""";
const string U = nameof parent; // Parsing[1002] invalid constexpr
const string V = nameof static; // Parsing[1002] invalid constexpr
}Trait
class A {}
trait T {
require extends A;
public function f(): void {
// Types are sketchy but match T::class and self::class for migration
$t = nameof T; // $t: classname<T> = String "T"
$s = nameof self; // $s: classname<T> = SelfCls; ClassName
$p = nameof parent; // $p: classname<A> = ParentCls; ClassName
$stat = nameof static; // $stat: classname<this> = LateBoundCls; ClassName
}
}Trait no parent
trait T {
public function f(): void {
$p = nameof parent; // $p: classname<nothing> (Typing[4074]) = ParentCls; ClassName
}
}Type alias
type T = int;
newtype U = string; // assume in different file
function f(): void {
$t = nameof T; // $t: typename<int> = String "T";
$u = nameof U; // $u: typename<U> = String "U";
}User Experience
nameof is a new keyword in VSCode's grammar, highlighted in blue with default
settings. Class names are autocompleted as they would be for new, without the
restriction on concrete classes.
Unsupported
Reified generics
class C {}
function f<reify T as C>(): void {
$t = nameof T; // $x: classname<T> (Parsing[1002]) = <bytecode_arg>
}
class R<reify Tr as C> {
public function m<reify Tm as C>(): void {
$t = nameof Tr; // $x: classname<Tr> (Parsing[1002]) = <bytecode_prop>
$t = nameof Tm; // $x: classname<Tm> (Parsing[1002]) = <bytecode_arg>
}
}
/**
* This specifies the current behavior of T::class when T is a reified generic,
* which is matched by the nameof implementation. The main difference is that
* function-bound reified generics are parameters and class-bound ones are
* properties. Since they're type structures, we just query the 'classname'
* field and throw if it doesn't exist e.g. type structure for `int`. The
* typechecker raises an error if the generic is not bounded by a class type.
*
* <bytecode_arg>
*
* BaseL $0ReifiedGenerics Warn Any
* QueryM 0 CGet EI:0 Any
* BaseC 0 Warn
* QueryM 1 CGet ET:"classname" Any
*
* <bytecode_prop>
*
* CheckThis
* BaseH
* Dim Warn PT:"86reified_prop" Any
* QueryM 0 CGet EI:0 Any
* BaseC 0 Warn
* QueryM 1 CGet ET:"classname" Any
*/Hphp Hack Doc HIPs Needs Concrete (`hphp/hack/doc/HIPs/needs_concrete.md`)
Author: Max Heiber
Status: accepted and implemented
HIP: `__NeedsConcrete` attribute
Real-World Example
This has been modified for open-source audiences. See the original document for the original code
There are tons of methods you can call in WWW today that will immediately blow up due to weaknesses in abstract checking. For example, a call to AssocBackedEntQuery::query() straightforwardly fails with "cannot instantiate abstract class". Code (abridged):
abstract class AssocQuery
extends Fetcher
implements QueryHasTime, QueryBackedByAssoc {
protected function cloneQuery(): this {
return self::query();
}
}
trait Fetcher {
final public static function query(): this {
return new static();
}With this proposal, we prevent calling AssocQuery::query and cloneQuery directly via the __NeedsConcrete method attribute: the methods are then safe with no other changes to WWW.
**Motivation**
Errors from attempting to access abstract methods, constructors, and constants account for one of the largest known categories of runtime errors preventable with better type-checking:
SEV prevention: redacted
Dev Efficiency: There are redacted static methods in WWW that will fail if called (D71133334). Users work around this today through fixing runtime errors and maintaining complicated invariants. We can shift these errors left.
* Both an over and under-estimate: this proposal doesn't make classname or class pointers any safer, that is saved for potential follow-on work un-blocked with this HIP. Under-counting comes from gaps in our ability to attribute.
**Solution**
Solution: High-level explanation
Definitions:
abstract-safety:hhis abstract-safe if it prevents attempts at runtime to call a constructor that doesn't exist, call an unimplemented abstract method, or access an unimplemented abstract constant.- A concrete class is a class that is either non-abstract or is final+not-
__ConsistentConstruct. The intuition behind this definition is "in a concrete class there are no unimplemented members".
Invariant: In a __NeedsConcrete method, we know that static points to a concrete class.
The checks introduced in this HIP all follow from leveraging or preserving the invariant to get more abstract- safety. I suspect with this proposal we'll be abstract-safe modulo dynamism, modulo classname<T>/class<T>, and existing type holes.
Here's one of the ways we leverage the invariant:
- In a
__NeedsConcretemethod,static::abstract_method()is allowed
Here's one of the ways we preserve the invariant:
If method `Abs::m` is marked `__NeedsConcrete`, and `Abs` is not a concrete class, then `Abs::m()` is not allowed
Why final, non-\_\_ConsistentConstruct classes are considered concrete
Because there's nothing wrong with code like the following, which is really common in WWW (abridged from real code). There are redacted cases in WWW (D71548214) where treating final+not-__ConsistentConstruct classes as concrete prevents spurious hh errors:
abstract class GraphQLClientBaseArtifactData
implements IGraphQLClientArtifactData {
private function __construct()[] {}
public static function addToOutput(
GraphQLQueryClientContext $context,
inout GraphQLHackClientSandboxCacheOutput $output,
)[write_props]: void {
static::addToOutputImpl($context, inout $output);
}
}
abstract final class GraphQLClientBloksQueryInputArtifactData
extends GraphQLClientBaseArtifactData
implements IGraphQLClientArtifactData {}See also why __ConsistentConstruct matters, below
Solution By Example
These examples are all adapted from the test suites for D71745959 and D69055023
See the next section for an algorithm sketch and link to full algo
We error when there's an access of an abstract member via static and static isn't known to point to a concrete class:
<?hh
abstract class A {
public static function m1(): void {
// hh error: unsafe because `static` might not refer to a concrete class.
static::m2();
}
public static abstract function m2(): void;
}The easiest fix in such cases is to add <<__NeedsConcrete>>:
<?hh
abstract class A {
public static function m1(): void {
// hh error: unsafe because `static` might not refer to a concrete class.
self::m2();
}
<<__NeedsConcrete>>
public static function m2(): void {
static::m3(); // ok: the attribute ensures `static` refers to a concrete class
}
public static abstract function m3(): void;
}The following shows how we check calls to __NeedsConcrete methods where the receiver is a class ID. Nice and simple, the receiver must be concrete:
<?hh
abstract class A {
<<__NeedsConcrete>>
public static function m1(): void {
static::m2(); // ok: the attribute ensures `static` refers to a concrete class
}
public static abstract function m2(): void;
}
<<__EntryPoint>>
function main(): void {
A::m1(); // hh error and runtime error
}Note that the runtime behavior is for parent to forward a value for static. The following example shows both what happens at runtime and my first stab at an error message:
I admit that the error message is hard to understand, but it's merely making explicit what was previously hidden complexity
<?hh
abstract class C1 {
<<__NeedsConcrete>>
public static function m2(): void {
static::m3();
}
public static abstract function m3(): void;
}
abstract class C2 extends C1 {
public static function m1(): void {
// Cannot call `C1::m2` (a `<<__NeedsConcrete>>` method) via `parent`. It requires `static` to refer to a concrete class, but `parent` sets `static` to a class that may not be concrete. (Typing[4490])
parent::m2();
}
}
class C3 extends C2 {
public static function m3(): void {
echo "m3\n";
}
}
<<__EntryPoint>>
function main(): void {
C3::m1(); // prints "m3"
// C2::m1(); // fatal: "cannot call abstract method C1::m3()"
}No errors here:
<?hh
abstract class A {
<<__NeedsConcrete>>
public static function m1(): void {
static::m2(); // ok
}
<<__NeedsConcrete>>
public static function m2(): void {}
}abstract final classes that are non-__ConsistentConstruct are common in WWW. We treat such cases as concrete, so there are no errors here:
<?hh
abstract class C1 {
abstract const int X;
<<__NeedsConcrete>>
public static function nc(): void {}
public static abstract function abs(): void;
}
// C2 is concrete because it is final+non-__ConsistentConstruct
abstract final class C2 extends C1 {
const int X = 3;
public static function m(): void {
// all the following are ok because C2 is concrete
static::nc();
static::abs();
static::abs<>;
static::X;
}
public static function abs(): void {}
}Here's why __ConsistentConstruct matters. C2::m() below would fail at runtime, since it would try to instantiate an abstract class:
<?hh
<<__ConsistentConstruct>>
abstract class C1 {
abstract const int X;
<<__NeedsConcrete>>
public static function nc(): void {
new static();
}
public static abstract function abs(): void;
}
// C2 is *not* concrete even though it is `abstract final`.
// C2 has an unimplemented member (the constructor)
abstract final class C2 extends C1 {
const int X = 3;
public static function m(): void {
static::nc(); // runtime error - because it calls `new` and the constructor for C2 doesn't exist
}
public static function abs(): void {}
}As always, if a method is __NeedsConcrete then static refers to a concrete class:
<?hh
<<__ConsistentConstruct>>
abstract class A {
abstract const int X;
public static abstract function abs(): void;
<<__NeedsConcrete>>
public static function ok(): void {
static::X; // ok
static::abs<>; // ok
new static(); // ok
}
}Hierarchy checks, similar to the rule that an abstract method cannot override a non-abstract method:
<?hh
abstract class A {
public static function nc(): void {}
public abstract static function abs(): void;
}
abstract class B extends A {
// hh error: Cannot declare this method as __NeedsConcrete, since it overrides a non-__NeedsConcrete method (Typing[4487])
<<__NeedsConcrete>>
public static function nc(): void {
static::abs(); // HHVM error
}
public static abstract function abs(): void;
}
function example(classname<A> $klass): void {
$klass::nc();
}
<<__EntryPoint>>
function main(): void {
example(B::class);
}Implementation
Here's a sketch of how we check static::foo() or MyClass::foo():
let containing_class_is_final_and_concrete cls =
final class && (non_abstract cls || non_consistent_construct cls)
let static_points_to_concrete_class env =
containing_method_has_needs_concrete_attribute
|| not env.in_static_method
|| containing_class_is_final_and_concrete env.containing_class
let check_static_call receiver env = match receiver with
| CIstatic when not (static_points_to_concrete_class env) ->
if callee_has_needs_concrete_attribute || callee_is_abstract then
error ()
| CI class_name when callee_has_needs_concrete_attribute && (not class_is_concrete class_name) -> error ()
| _ -> ....The implementation (D71745959) checks accesses of constructors, constants, and methods via super, self, parent, and class ID, but the rules are obvious once it's clear what the invariants are.
Migration Plan
- Codemod to add the
__NeedsConcreteattribute to redacted methods in WWW:- Fixpoint until nothing changes:
- If a static method
mof a non-concrete class that is not __NeedsConcrete accesses an abstract or __NeedsConcrete method via static/self/parent, add the __NeedsConcrete attribute tom - If
NeedsConcretemethodC2::moverrides a non-\_\_NeedsConcretemethodC1::m, add the__NeedsConcreteattribute toC1::m
- If a static method
- Note: the logic will be very similar to that implemented in the analysis: D71133334
- Fixpoint until nothing changes:
- fixme or fix the small number of new errors in WWW
Trade-offs and Stats about WWW
Benefits:
- Safer code
- Easier to for users to write+maintain code
- My understanding is that today's WWW is the result of great effort to make code work well as much as possible
- complex invariants around hierarchies and concreteness
- fixing issues discovered in production
- fixing issues discovered in testing
- We reduce future WWW development and maintenance cost through shifting left
- My understanding is that today's WWW is the result of great effort to make code work well as much as possible
- Un-block further Safe Abstract work for more safety
Cost:
__NeedsConcreteneeds to be learned by users- My take: it's better to have Hack track concreteness for you than to manage complex invariants around
selfandstaticwithout tool assistance
- My take: it's better to have Hack track concreteness for you than to manage complex invariants around
- Implementation complexity: not much, fairly orthogonal D71745959
- Changes to existing code:
Appendix
Potential Follow-on Work: class pointers and class names
class<T> and classname<T> are still not abstract-safe, but may be addressed in a future proposal.
We break the problem into pieces
- This proposal only adds
<<__NeedsConcrete>> - No new checking for
class<T>orclassname<T>(yet)
Why:
- Easier migration than moving WWW to full Safe Abstract checking at once
- Incremental value and learnings
- Ordering:
__NeedsConcretewould be needed forclass<T>/classname<T>checking anyway
Potential Follow-on Work: no longer need to pass value for `static` through `self` and `parent`
Python has inheritance for static methods, similar to Hack, but more clearly separates which classes get to access the current runtime method.:
class C:
@classmethod
def m1(cls):
# `cls` points to the current class
# like `static` in Hack static methods or `this` in Flow static methods
cls.m2()
...
@staticmethod
def m2():
# No access to "current runtime class",
# is more like a Java static method
...Jan Oravec and Vassil Mladenov have iirc mentioned wanting to get Hack closer to Python's explicitness and clarity w.r.t. late static bindings. And Mistral Contrastin has pointed out that explicitly passing the class at least provides a helpful intuition for how PHP/Hack Late Static bindings work.
Why not fine-grained Method Requirements
A source of incompleteness is:
- Method C::
lsbis __NeedsConcrete - Child1 inherits
lsband does not override it - All the abstract things accessed in
lsbare concrete inChild1 - So we have a method (
Child1::lsb) which is__NeedsConcretebut doesn't need to be:
<hh
abstract class C {
public abstract static function m(): void;
// C::lsb is the common ancestor of weird hierarchy members Child1::lsb and Child2::lsb
// The --needs-concrete analysis will show that C::lsb should be <<__NeedsConcrete>>
// because ::lsb() will fail if the LHS is an abstract class that does not have an implementation of `m`
public static function lsb(): void {
static::m();
}
}
abstract class Child1 extends C {
public static function m(): void {}
// (unwritten) member Child1::lsb is a weird_hierarchy_member
// because it is <<__NeedsConcrete>> but it doesn't actually need a concrete receiver
}
abstract class Child2 extends C {
public static function m(): void {}
// (unwritten) member Child2::lsb is a weird_hierarchy_member
// because it is <<__NeedsConcrete>> but it doesn't actually need a concrete receiver
}
function example(): void {
// incompleteness. If our checking were more precise, we would know that
// Child1::lsb() is safe at runtime. However, Safe_abstract_check will
// produce an error here (assuming C::lsb is marked as <<__NeedsConcrete>>)
Child1::lsb();
}We could address the incompleteness by using a more fine-grained notion than just concrete/non-concrete.
However, the incompleteness does not seem so bad in practice. The analysis in D71548214 found redacted such cases of this in WWW. Even if we're off by an order of magnitude, that's still not many.
Prior Art from Other Languages
**Prior Art from Other Languages for type-checking Late Static Bindings**
Supplement to Proposal: Shift Left static:: and classname errors
Python/Pyright:
- Has late static binding (static methods that are inherited where
clsrefers to the runtime rather than lexical class) - Has a similar soundness hole to Hack's (GitHub issue I opened)
Python/Pyre:
- Has late static binding (static methods that are inherited where
clsrefers to the runtime rather than lexical class) - Doesn't prevent even direct calls to abstract static methods (example), making it easy to get runtime errors.
TypeScript:
- Has late static binding:
thisin a static method refers to the runtime class, and static methods are inherited. - Abstract and interface methods cannot be static, so similar problems to Hack's do not arise.
Flow
- No abstract classes or static interface methods, so similar problems to Hack's do not arise.
Java, Scala, Kotlin, and C\#:
- Abstract and interface methods cannot be static, so similar problems to Hack do not arise.
- Notes:
- I tested with C# 7, newer versions may be different
- Java afaict does not have LSB: it doesn't seem possible to get a handle to the runtime class as distinct from the lexical class. Not sure about the other languages (but moot point since there's no abstract static)
- Scala and Kotlin technically do not have static methods, and instead have "companion objects". Afaict, companion objects have no abstract members.
- Banning abstract static methods seems to be the respectable thing to do, but imo the ship has sailed for WWW. tbgs found redacted uses of 'abstract.+static' and 'static.+abstract' and that doesn't even cover occurrences in interfaces.
Prior Art for Hack
The now-partially-outdated-but-perhaps-informative proposal Proposal: Safe Abstract (formerly known as Shift Left Abstract Static). That proposal also addresses classname<T> and class<T> and has formalization in Alloy and Lean
See also the Prior Art section of Proposal: Safe Abstract (formerly known as Shift Left Abstract Static).
Hphp Hack Doc HIPs Ownership And Linearity (`hphp/hack/doc/HIPs/ownership_and_linearity.md`)
Title: Ownership and Linearity Tracking
Start Date: June 26 2020
Status: Postponed
Summary
A system for tracking and enforcement of ownership for Objects, specifically
via linearity.
Feature motivation
There are multiple existing or future features for which ownership tracking is
required for soundness. The major usecase of this is the Purity project, which
will have its own HIP in the coming weeks. However, there are other features,
such as const classes and disposables, for which language-level ownership
tracking is necessary for soundness. We expect there to be additional future
features that can make use of this system.
Additional benefits include:
- HHVM can produce optimizations based on the guarantees described below when utilizing these features.
- Provability that an API requesting a unique value is actually given one
User experience
The Four States of Ownership
There are four states of ownership:
- Unowned (The default)
- Owned
- Borrowed
- MaybeOwned (temp name, looking for alternatives).
The main differences between each state are the operations allowed on an object
of that state. Ownership is a property of the linearity of an object. Owned and
Borrowed objects are enforceably linear, Unowned objects are not, though they may
be naturally, and MaybeOwned may or may not be forcibly linear.
Described in terms of the operations allowed on them, the states are as follows:
- Unowned - The default state in hack. May already have aliases
(i.e. be nonlinear). May be freely aliased. - Borrowed - Cannot be aliased and is a non-owner of the object. The owner
of the object is a (possibly indirect) caller, so no other code with a reference
to this object can run until this function returns (or further lends the object
to a more deeply nested callee). Because of this restriction, we consider the
object to still be linear in this state. If the caller did not opt in to stricter
tracking aliases may already exist, but the borrowing code will not make new ones.
See the next section for more details - Owned - Does not have aliases and cannot be aliased. May be lent to a
(synchronous or immediately awaited async) callee, which preserves the invariant
that at most one runnable frame has a reference. Can transfer ownership and can
release ownership, moving the object into the unowned state. - MaybeOwned - May be an object in any of the previous 3 states. This state is
distinct from Borrowed because some features built on ownership tracking (purity
and const classes) need to allow mutations only on linear (i.e. Owned or
Borrowed) values. Functions that only need to read from an object will be more
widely reusable if they receive objects in this state rather than Borrowed.
See the next section for more details
As a Table, where LHS is current state, and Column is whether they have a specific
capability:
| Has alias | May alias | May move/disown | |
|---|---|---|---|
| Unowned | maybe | yes | no |
| Owned | no | no | yes |
| Borrowed | maybe* | no | no |
| MaybeOwned | maybe | no | no |
* This is a choice for the caller. If they opt in to stricter tracking, then
Borrowed cannot have an alias. See next section.
Described in terms of what parameters they may be passed to given that
the parameter is defined to be a specific state:
- Unowned - Can be passed to Unowned or MaybeOwned. Whether it can pass to
borrowed depends on whether the caller opts in to stricter tracking. See the
next section for more details. - Owned - Can be passed to Borrowed or MaybeOwned, or may transfer ownership to Owned.
- Borrowed - Can be passed to Borrowed or MaybeOwned.
- MaybeOwned - Can be passed to MaybeOwned.
As a Table, where LHS is current state and Column is whether they can be passed
to a param of that state (or used as the $this in a method invocation,
when otherwise permitted):
| Unowned | Owned | Borrowed | MaybeOwned | |
|---|---|---|---|---|
| Unowned | yes | no | maybe* | yes |
| Owned | no | moved | yes | yes |
| Borrowed | no | no | yes | yes |
| MaybeOwned | no | no | no | yes |
* This is a choice for the caller. If they opt in to stricter tracking, then
Borrowed cannot have an alias. See next section.
Unowned and Borrowed
The choice of whether or not unowned may flow into borrowed is a subtle one -
one that developers will never have to consider under this proposal. The
usecase for not being able to pass unowned into borrowed is enabling of mutations
on linear objects (when they'd be banned on nonlinear ones).
Consider Rust. They have locally mutable values, which are analogous to our
Owned values, and mut &s and &s, respectively analogous to our Borrowed and
MaybeOwned. The key is that rust does not have an unowned state - all values are
owned by a single reference. When a system requires linearity to enable
mutability, passing an unowned value into the equivalent to a mut & is obviously
unsafe, as that would result in mutations happening to nonlinear values.
Under this proposal, systems that require linearity for the purpose of tracked
mutations (such as Const Classes and Purity), would implicitly disallow passing
unowned values to parameters etc marked as borrowed. Developers would get an
error specifically about this telling them why and they will merely need to
conform to these restrictions, often via use of the MaybeOwned state.
One may note that this is in fact the main reason for the existence of the
otherwise quite similar Borrowed and MaybeOwned state. For systems that don't
require this stricter ruleset, those states are functionally identical (although
still not interchangeable). For the most part, developers should not need to know
or care about this.
Concurrent uses of tracked objects
Borrowed, Owned, and MaybeOwned values may not be used multiple times
in the same statement or concurrent block as this creates implicit aliases,
breaking linearity. Further, objects of those states may not be passed to async
functions if they are not immediately awaited.
function takes_borrowed(borrowed Foo $b): void {...}
async function gen_takes_borrowed(borrowed Foo $b): Awaitable<void> {...}
function takes_two_borrowed(borrowed Foo $b, borrowed Foo $c): void {...}
function example(owned Foo $x): void {
takes_borrowed($x); // fine
takes_two_borrowed($x, $x); // banned
// this might be fine because takes_borrowed($x) is finished before bar is called, but banned in V1 to be safe
takes_two_borrowed(takes_borrowed($x), $x);
takes_two_borrowed($x, takes_borrowed($x)); // as previous
// as previous. This might be fine because foo($x) is run synchronously, but banned in V1 to be safe
takes_two_borrowed(takes_borrowed($x), takes_borrowed($x));
takes_two_borrowed(await gen_takes_borrowed($x), await gen_takes_borrowed($x)); // banned
gen_takes_borrowed($x); // banned - must await immediately
}For the above, any use of a local counts as a borrow and the same logic applies
for concurrent blocks as these individual statements. Note that invocations likefoo($x) cannot be returning $x internally as that would require aliasing a
borrowed value.
Declaring Objects as Tracked
You will note below that there is no way to explicitly state that a value
is unowned. There is no usecase we could determine for which this was a useful
designation.
Note that only one of the following may be used per method object, parameter,
and return value, but multiple may occur in the same declaration header.
Parameters
Parameters may be declared to be any of Unowned, Owned, Borrowed, and MaybeOwned
public function foo(IFoo $foo): Foo { return new Foo(); }
public function foo(owned IFoo $foo): Foo { return new Foo(); }
public function foo(borrowed IFoo $foo): Foo { return new Foo(); }
public function foo(maybeowned IFoo $foo): Foo { return new Foo(); }Return Types
Return values may be declared as either unowned or Owned
public function foo(IFoo $foo): Foo { return new Foo(); }
public function foo(IFoo $foo): owned Foo { return new Foo(); }Returning a Borrowed/MaybeOwned value is forbidden, as doing so would create an
alias. Returning an owned value implicitly transfers ownership to the caller.
Method Objects
The object on which a method is called may be declared as Unowned, Borrowed, or MaybeOwned
public function foo(IFoo $foo): Foo { return new Foo(); }
public borrowed function foo(IFoo $foo): Foo { return new Foo(); }
public maybeowned function foo(IFoo $foo): Foo { return new Foo(); }While it is logically sound for $this to be owned, with ownership transferred to
the method call, this is not permitted due to runtime considerations. There are
major issues with $this being available via things like stack traces rather
than just within the method itself. Further, unsetting $this is not something
the runtime can support at present.
Moving Between States
Creating Owned Values
When an object is created it must explicitly opt in to the owned state by using
the own keyword, e.g. $x = own new Foo();. Any object that is not explicitly
created as such is unowned.
Transferring Owned Values
For those who have used C++ and Rust, transferring ownership will be somewhat familiar.
Consider the following functions, one that requires to be given an Owned value
and the other that creates one as above:
function takes_owned(owned Foo $foo): void {...}
function creates_owned(): void {
$f = own new Foo();
// would like to give ownership of $f to `takes_owned`
}Ownership transference is done via the move keyword
function creates_owned(): void {
$f = own new Foo();
takes_owned(move $f);
}In order to maintain linear ownership, after moveing a value, the variable
initially containing that value is unset (i.e. KindOfUninit). In the previous
example, attempting to use $f after invoking takes_owned would fail.
Additionally, as you would expect, attempting to move the same value twice in
the same statement/concurrent block is illegal.
Note that the move keyword isn't allowed on the result of an own
expression. Rather, it is always implicit when necessary e.g. in takes_owned(own new Foo());.
The other location of ownership transfer is at the return site of a function
marked as returning an owned value, as in the following
function returns_owned(): owned Foo {
$f = own new Foo();
return $f;
}Note that the move keyword is not required here, as it is trivially determinable
from the signature of the enclosing function that an ownership transfer is required.
The own keyword may be used to receive an owned value from a function marked
as returning an owned value. Additionally, if the expression being returned is
one on which own can be used, own is also not required, and, in order to
remain consistent, disallowed:
function returns_owned(): owned Foo {
return new Foo();
}
function returns_owned2(): owned Foo {
return returns_owned();
}
function returns_foo(): owned Foo { return new Foo(); }
function creates_owned_foo(): void {
$f = own returns_foo();
}Note that it is possible to return an owned value in an async function. In order
to receive that owned value, the caller must immediately await and own the
result:
async function gen_returns_foo(IFoo $foo): owned Awaitable<Foo> { return new Foo(); }
async function gen_creates_owned_foo(): Awaitable<void> {
$f = own await gen_returns_foo();
}Awaitable is the only situation in which the ownership declaration does not apply
directly to the item following it. In this way, it is similar to __Soft.
Conditionally moveing values is currently disallowed. If, in a future version,
we change the moved variable to be null instead of unset, it is possible we
can allow this.
As a reminder, any value that may be owned but isn't is implicitly disowned.
Disowning Values
It is sometimes desired to disown a value midway through a function. This is done
via the disown keyword.
function foo(owned Foo $foo): void {
// cannot alias $foo here
$unowned_foo = disown $foo;
// $unowned_foo is unowned. $foo is unset
}Note that similarly to moveing a value, disowning one unsets thedisowned variable. In the same vein, a variable may not be conditionally disowned.
Linearly Lending Values
Owned values may be lent to other functions promising to maintain linearity, and
borrowed values may be lent to further functions. This is handled without
requiring additional keywords.
class Foo {
public borrowed function borrow(): void {}
}
function foo(owned Foo $foo): void {
borrow($foo);
$foo->borrow();
}
function borrow(borrowed Foo $foo): void {
borrow_again($foo);
$foo->borrow();
}
function borrow_again(borrowed Foo $foo): void {}Additional information
Interactions with `null`
null is a valid value in any state, as it is not actually an object but rather
the lack of one.
Hierarchical requirements
Logically, the subtyping rules for the states can be thought of as follows,
where (|) is subtyping and ~~> is coercion:
MaybeOwned
/ \
Borrowed <~~ UnOwned
|
OwnedFor simplicity, however, the initial implementation will require that
hierarchies be invariant in their usages of ownership states. It seems likely
that we can enable owned return positions to be subtypes of unmarked return
positions, but the rules for, and usefulness of, subtyping relationships for
parameters becomes less clear.
This includes the usage of the __Unownable and __ReturnsBorrowedThis
attributes, to be described below.
Properties of objects cannot be owned.
At present, properties (including container contents) are always unowned.
It is possible that this may be implementable in a future version.
Preservation of Single Ownership Type Per Variable Name
In order to enable performant tracking by HHVM, while retaining a reasonably
simple implementation within the emitter, variables may be used to reference
at most one ownership state. However, a single variable may be used to
reference multiple objects of the same ownership state.
class Foo {}
function my_function(
owned Foo $owned,
borrowed Foo $borrowed,
maybeowned Foo $maybe,
Foo $unowned,
): void {
$owned = own new Foo(); // fine, still owned
$disowned = disown $owned; // fine, new disowned variable
$owned = own new Foo(); // fine, owned again
$owned = $disowned; // error
$borrowed = own new Foo(); // error
$borrowed = $disowned; // error
$maybe = own new Foo(); // error
$maybe = $unowned // error;
$unowned = own new Foo(); // error
$owned = own new Foo(); // fine, still owned
$disowned = own new Foo(); // error
$disowned = new Foo(); // fine, creating implicitly unowned value
$unowned = new Foo(); // fine, creating implicitly unowned value
$new_unowned = new Foo(); // fine, creating implicitly unowned value
}Capturing values in closures
Locals that contain Owned, Borrowed or MaybeOwned values cannot be captured as
that creates an implicit alias, breaking linearity
class Foo {
public int $foo = 0;
public borrowed function capturesMutable(): void {
$x = () ==> { $this; }; // error
}
public maybeowned function capturesMaybeMutable(): void {
$x = () ==> { $this; }; // error
}
}
function captures_owned(
owned Foo $obj1
borrowed Foo $obj2,
maybeowned Foo $obj3,
): void {
$x = () ==> {
// these are all errors
$obj1;
$obj2;
$obj3;
};
}Ownership in Constructors
Constructors pose something of an unique problem for ownership tracking. In
order for tracking to work, one must be allowed to own the result of a
constructor call. However, if linearity is broken within the constructor, then
that no longer holds true.
The solution landed upon is for constructors to be opt-out for linearity instead
of opt-in. In the standard case, $this within a constructor is in the borrowed
state. Following the termination of the constructor, the rules apply as stated
above. The opt-out mechanism is via a new __Unownable attribute, which
indicates that the result of that constructor may not be owned. Additionally,
hack errors triggered in a non-opted-out constructor will initially suggest
opting out as a last resort after other standard suggestions.
This conclusion resulted from analysis of patterns within the FB codebase.
Of the very large number of constructors, <3% of them would need to be opted out
if no further work was done. However, of that <3%, >95% of them are due to
invoking a helper function on $this which is not accepting of a borrowed value.
Upon inspection of a random sampling of those helper functions, a large
majority of them are trivial to allow accepting borrowed values with no other
changes (except for potentially marking a further function). Further, >4% of the
problematic constructors arise from capturing $this in a closure, which may
become legal in a future update (see Future possibilities section). Thus, only 1%
of the problematic cases are ones which require more serious refactoring. The
simplest solution for those are to convert them to a builder pattern in which the
builder function invokes the constructor and then does any problematic behaviour
following it.
We may choose in the future to remove this method of opting out.
Interactions with Memoize
Memoized functions may not return values as owned, as doing so would necessarily
allow creating multiple aliases to the same object. For objects implementingIMemoizeParam, the getInstanceKey method must be valid to invoke on the
passed object for whichever state the parameter is marked. (e.g. if the param
is borrowed, getInstanceKey must use a Borrowed or MaybeOwned this);
Prior to rollout, we will determine whether this is actually necessary or if we
can globally enforce a MaybeOwned this for getInstanceKey methods
Interactions with inout
An inout parameter can neither be Borrowed nor MaybeOwned as these are
tantamount to returning such values, which is banned.
For simplicity, an inout parameter may not be owned. It is possible that a
future version of this feature will allow this if usecases arise.
Interactions with the Mutable Builder Pattern
The mutable builder pattern is one in which a class contains setters that modify
a property and then return $this. This can then be used as a chain to "build up"
the object. This becomes a problem when ownership tracking comes into play, as
if $this is tracked, then the return statement creates an alias.
The problem then becomes how to allow these objects to be built up in the case
where the object is tracked while not diminishing the experience for unowned
objects for general developers.
The solution to this problem for this proposal takes the form of a temporary
Hack. A function can be marked with the magic __ReturnsBorrowedThis attribute.
In the presence of this attribute, the return value can only be used by the
caller if the object the method is called on is unowned. Otherwise, the method
may be called, but the return value must be thrown away.
class Foo {
private ?int $val;
private ?int $val2;
<<__ReturnsBorrowedThis>>
public borrowed function set1(int $val): this {
$this->val = $val;
return $this;
}
<<__ReturnsBorrowedThis>>
public borrowed function set2(int $val): this {
$this->val2 = $val;
return $this;
}
}
function useit(): void {
// this is the standard chaining version
$unowned = new Foo();
$unowned
->set1(42)
->set2(1234);
// here, since return value cannot be used, must use multiple statements
$owned = own new Foo();
$owned->set1(42);
$owned->set2(1234);
}This is not ideal, but it is required for safety of tracking. We hope that in
the future additional features can be added to the language obviating the need
for this hack. See the discussion on void chaining linked below.
Note that methods with this attribute must act on a borrowed this and return
exactly $this.
IDE experience
The new keywords will be supported in a first class manner with respect to syntax highlighting.
There will be a large number of new error types both in the typechecker and parser.
Due to the nature of this system, most developers won't see any errors due to
the inherent flexibility of the default state. When annotated code calls unannotated
code, we can straightforwardly suggest how to annotate the unannotated code to
facilitate progress. When annotated code calls annotated code, the error messages
will be similar to standard type errors where they will explain why the different
states are incompatible and it will be up to the user to fix them.
Implementation details
Interaction with other features
The initial version of the feature will have no interactions with other hack
features other than those explicitly expressed above.
Following the rollout of this feature, we expect to utilize it for sound
implementations of Purity, Const Classes, and Disposables. Note that the below
sections are examples rather than confirmed decisions. It is possible that
details about the interactions will change.
Disposables
Disposables currently have their own implemented version of ownership tracking
wherein disposables are "owned" by their using statement, and may be lent or
given to called functions. This is completely subsumed by this more complete
proposal, however they will also have additional restrictions (such as
prohibiting disowning them).
As an example, here is what currently exists
/* Detailed source-code truncated for AI context efficiency. */and how it would look using ownership management (note that all error
messages are examples):
class MyDisposable implements IDisposable {
// won't require `borrowed` on __dispose method because it's always the case.
public function __dispose(): void {}
}
function returns_disposable(): owned MyDisposable {
using $foo = new MyDisposable();
// here $foo is borrowed
// error: may not return borrowed values
// objects in a using block are always borrowed
// return $foo;
return new MyDisposable();
}
function accepts_disposable(borrowed MyDisposable $md): void {
// error: may not `use` borrowed values (or must `use` owned values)
// using ($md);
// error: may not return borrowed values
// return $md;
}
function takes_mixed(mixed $m): void {}
// Disposable Parameters must always be Borrowed.
// function illegal_param(MyDisposable $m): void {}
function example(): void {
// Disposable objects may not be disowned
// returns_disposable returns an owned value
// this value is implicitly disowned here
// $x = returns_disposable();
// Disposable objects may not be explicitly owned
// they must be used by a `using` block
// $x = own returns_disposable();
using ($x = returns_disposable()) {
// here $x is borrowed
// cannot pass borrowed object to function expecting unowned
// takes_mixed($x);
} // here $x is destroyed following lending itself to __dispose
// Variable $x is undefined, or not always defined Hack(2050)
// echo $x;
}However, when using ownership management it becomes possible to implement things
like potentially transferring ownership of disposables into called functions
that must then use them. There are a handful of more powerful features
required by the HSL IO library in order to make its implementation disposable.
Having them powered by ownership allows for these potential additions.
Given that they are never unowned, it is illegal to have a typehint of a
disposable type without an ownership annotation, typically borrowed or owned.
Conceptually, the object is "owned" by the using statement and is lent to the
block scope.
Purity
We're not going to go into too much detail on purity as it is still an
experimental feature, and we don't want to have discussions on its merits or
decisions at this location.
This is another example of the discussion above regarding requiring linearity
for the purpose of mutability.
In the experimental version, there are 4 states of mutability:
- Immutable (the default) - an immutable nonlinear value
- Owned Mutable - an owned linear value that is mutable
- Borrowed Mutable - a borrowed linear value that is mutable
- Maybe Mutable - a value that may or may not be linear, so is immutable but also
must be treated as if it was linear.
The states of this proposal can replace the previous states 1-1:
Immutable -> unowned
Owned Mutable -> owned
Borrowed Mutable -> borrowed
Maybe Mutable -> MaybeOwned
If Purity is rewritten to use the ownership annotations, it must only add
the 2 rules, everything else directly falls out of this proposal.
- Only Owned or Borrowed objects may be mutated.
- Value types may always be mutated.
One might note that it's possible to pass an unowned value into the system
from impure code calling pure code, but that is no different than the current
state of the feature.
To be completely clear, this will completely replace the experimental mutability
annotations added by the reactivity project.
Const Classes
The design for const classes prior to this proposal worked fairly simply. They
can be modified within the constructor as well as from within private methods
called by the constructor, however once the constructor finishes, the classes
"lock" and become immutable.
However, this has a pretty unfortunate typehole:
const class MyConstClass {
private int $x = 0;
public function __construct() {
$this->can_mutate();
}
private function can_mutate(): void {
$this->x = 42;
}
private function my_unrelated_method(): void {
// do stuff
}
public function oops(): void {
$this->my_unrelated_method();
$this->can_mutate(); // uhh....
}
}
function example(): void {
$cc = new MyConstClass();
$cc->oops(); // explode
}This problem is solvable via a combination of ownership management and making
const classes more powerful. The solution is simple: opt const classes into
the stricter ownership tracking and making them immutable when unowned.
const class MyConstClass {
private int $x = 0;
public int $even_more = 42;
public function __construct() {
$this->can_mutate();
}
private borrowed function can_mutate(): void {
$this->x = 42;
}
private function my_unrelated_method(): void {
// do stuff
}
public function oops(): void {
$this->my_unrelated_method();
// error: cannot call function with borrowed $this
// $this here is unowned
// $this->can_mutate();
}
}
function example(): void {
$ncc = own new MyConstClass();
$ncc->even_more++;
$cc = disown $cc;
$cc->oops(); // safe!
}In this system, all instances or typehints of a const class will be opted-in to
the stricter tracking. Further, const classes can now make use of the
extended constructor pattern!
HHVM Support
This feature will have full HHVM support and enforcement. Objects may only be
used as described above or else they will throw an exception. For cross-function
boundaries the enforcement will work similarly to type enforcement.
Codemodding
The only major codemod required for this feature is the interaction with
constructors described above to insert the __Unownable attribute where
necessary.
Other than that, no codemodding for this will be done except to convert
the current Disposables and temporary Mutability tracking feature to this
complete one. In that case, we will first do a static transformation from the
old attributes to the new system to confirm everything still typechecks.
Revert Plan
If, following rollout of this feature, we decide to remove it, reversion
will simply be a codemodded removal of the associated keywords. However,
if used within Purity, Const classes, and other major features, it becomes
increasingly difficult to roll back without exposing major soundness holes
in the language.
Note, however, that the initial plan is to have the system implicitly back both
disposables and the experimental Reactivity/Purity syntax under the hood,
so we should hopefully know prior to full rollout if revert is necessary.
Design rationale and alternatives
This design was created under a few basic principles:
- Opt-in feeling: It is important for a large codebase that this need not be the default
- Usability: The states are not conceptually difficult and hack-errors can
be used to direct newcomers towards correct patterns, as situations of the
form "You are doing A, which is wrong, but B would be OK" are common. - HHVM Enforceability and efficiency: In order to be used for optimizations,
HHVM must be confident of the guarantees. Therefore, it must be enforceable.
Therefore, it must be efficient.
This is a completely new segment of the language such that attempting to do this
via existing features is a nonstarter.
Not doing this prevents multiple projects, either via soundness issues or simply
lack of required featureset. See above for examples.
Alternative designs
There are multiple minor tweaks considered to the above syntax.
Instead of public borrowed function foo(): void {...}, we consideredpublic function foo(): void where this is borrowed {...}.
We decided against this as, while it makes it clear we're referring to this,
it requires modification of function-level where clauses and is extremely verbose.
It is worthwhile to note that static appears in the same location and is a modifier
that describes the state of $this
We considered the keyword owned instead of own for marking a value as
owned, but don't believe the extra two letters add anything.
We considered referring to the unowned state as disowned, but this is
confusing for things that are never owned.
As an alternative to __ReturnsBorrowedThis, we would like to resurrect the
void chaining discussion.
This allows us to use the fluid builder pattern on non-unowned code, but also
allows for the potential to completely remove the pattern of functions that
return $this for the purpose of chaining.
An alternative to the disown keyword is to use release. Bikeshedding welcome.
The __ReturnsBorrowedThis attribute is technically unnecessary as the emitter
can infer when it would be required and handle the situation appropriately. We
think it's better to be explicit here.
When owning the result of an async function, should the own be before or
after the await? We landed on before because you're owning the wrapped value,
not the Awaitable. Maybe there are arguments for the alternative?
Drawbacks
It is complicated and requires significant runtime and typechecker support.
Prior art
At present, there is only one major language implementing an ownership system.
There are a handful of languages implementing Linearity systems, however that
does not map particularly well to an imperative language like our own.
Rust's ownership tracking is more robust and allows for nested ownership
tracking (such as owned properties) and explicit lifetimes. This is made
possible as they have an AoT compiler that can do enforcement and analysis. We
have different constraints. Further, we do not require quite the same complexity.
This can more simply be described as borrowed T -> &mut T and own T -> T.
However, rust doesn't typically allow for unowned values. The closes it comes
is &T immutable references. These are references to an owned value that is
merely being utilized multiple times at once (and so must be immutable).
In general, our requirements are flipped. They want everything owned. We want
most things unowned with specific things being owned.
Additionally, there a handful of other languages actively exploring this space:
Swift's ownership manifesto
is designed based on different constraints and purposes. Ours is mostly to unblock other features,
while also enabling some performance gains. Their purposes are flipped.
Haskell
with tech talk:
Rather than discussing linearity of values, they describe linearity of functions,
such that for a fn a -> b, if b is consumed exactly once, then a is consumed
exactly once. They implement polymorphism by taking in closures and making the
overall linearity of the function be polymorphic upon the linearity of the
passed function. Further, they do not have the concept of borrowing linear
values, which is extremely important to our system.
Clean:
Clean has a similar system to Haskell except that it's type-based rather than
function based. It has some similar properties to our system, but considers
non-unique <: unique because of its functional nature, such that you can
pass uniq values into a function accepting non-uniq, but what you get out is
nonuniq. In general, these all apply to consumed inputs, thus they don't have
the borrowed state. They do have a generic on types to specify propagation
of uniqueness, but again, this requires the move in -> move out scenario.
Idris:
They use dependent typing for this and have discovered the need for a borrowed
state but have not yet handled the polymorphic MaybeOwned state. They have a
representation for Owned V Unowned, but specifically note that it doesn't allow
for representation of borrowed. It's more similar to Haskell's polymorphism due
to its lack of a borrowed state.
Also, from another paper or note:
Uniqueness types, in Idris, are being replaced by linear types
Papers on the subject:
Islands: aliasing protection in object-oriented languages
(by John Hogg) (paper available upon request):
The overall goal here is to create "islands" of objects in which they are, as a
whole, only referenced via a single "bridge" such that all modifications to
the island (or even access to it) must go through the bridge. Thus, they may
handle things like side effects, which are nonissues for const objects and
disposables, and general alias management and concept encapsulation.
Three major concepts: read unique and free where read is similar to const,
and the other two are about whether aliases are allowed.
A variable z many be unrestricted, read, unique, free, read and unique, or read and free.
Interestingly, here 'unique' means that there is only one reference to an object
via the heap (such as instance properties), and the rest are stack-based, andfree means there are no heap-based references to the object (even if the
object itself is on the heap).
A unique object is most often used via lending it out on the stack, similar to
our borrowed parameters. They don't need two keywords because unique appears
on things like instance properties to imply its the only heap-based reference
and on parameters to mean that it's a reference to a unique object. However,
in many ways, their free is similar to our owned. You can create a free
reference by destructively reading a unique heap-based reference. Further,free references can only be accessed via destructive reads (creating anotherfree refence or assigned into an unique one).
I honestly, don't understand this restriction and description.
If a method receiver is unique, then every parameter and the result must be
read or unique or free.
Unique is a transitive property. If an object’s acquaintances are aliased,
then the observable state of the object may be unexpectedly changed, even if
there is no aliasing of the object itself. Therefore, an object being accessed
as unique must not import or export any unprotected references. An unprotected
parameter could be retained by the object, however.
This doesn't apply to free receivers, because it doesn't matter.
This is an interesting approach for allowing tracking of members. However, this
doesn't solve our problem with the unique object being referenced from within
an unrestricted object, which they don't talk about as far as I can tell.
As a side note, they don't cover practical problems of code deduplication and/or
polymorphic states, which are the main problems we generally struggle with.
They do generally seem to have an opt-in system, however, in that that outside
an "island" everything can just be unrestricted, and only when interacting with
an island must things be annotated.
Alias burying: Unique variables without destructive reads (by Boyland)
Alias burying: when a unique field is read, any existing aliases are
required to be dead. Together with restrictions on aliasing across procedure
calls, alias burying can be checked by a static analysis in a modular fashion.
Their main goal is to be able to track mutations for safe concurrent reads, which,
while not identical to our goals, is more or less translatable.
They concur on our decision to mark misbehaved constructors
A constructor can keep a reference to the object it is creating in a globally
accessible location; not all constructors create unshared objects. Thus a
constructor that does create an unshared object should be declared as such.
...
Constructors in Java are not responsible for creating an object, just
initializing it. Therefore constructors that “return” unique objects are
annotated as having borrowed receiver
Their system of ownership and borrowing seems very similar to our own
An alias of a variable for our purposes is a variable that refers to the same
object as the first variable. This definition is nonstandard, but is convenient
for languages such as Java that do not have explicit dereferencing
A static alias is a field variable; a dynamic alias is a receiver, parameter,
local variable, or return value alias
A dynamic alias of an otherwise unique variable is also known as a “non-consumable,”
“uncaptured,” or “limited” variable.
Such a variable may be used for computation and copied into other local
variables or parameters (which become dynamic aliases) but must never be stored in a
field. In this paper, we refer to variables whose values may never be stored
(or returned) as borrowed variables.
They recommend using borrowed to refer to a value that may either by a dynamic alias
to an owned object or an unowned object.
However, they further recognize that the existence of a union state of this
description results in issues with tracking mutations.
Weakening a uniqueness variant to admit dynamic aliases makes it easier to
program with unique variables than with destructive reads alone, but this
weakening comes with a cost. One main purpose in avoiding aliasing was to make
it easier to isolate mutations; dynamic aliases make it harder to track down
all mutations.
They allow for unique static aliases (our borrowed) similarly to Hogg, above.
In general, their states are Unique, Unrestricted, and Borrowed. As far as I can
tell this works in a system that cares only about aliasing and not restricting mutations to
linear values because you don't need dynamic aliases that are required to be references
to unique objects. They allow modifications to Borrowed objects. This seems to
generally safe only because they mark mutating methods as synchronized, which
alleviates any interleaving concerns automagically. I'm not quite clear on how
that works, however, given that reads can happen in nonsynchronized methods.
When a unique field of an object is read, all aliases of the field are made undefined.
If we can determine statically that no alias is ‘buried alive’, no dynamic
checks will be necessary.
Their goal is to statically prove that this invariant holds true such that
unique properties must actually be unique dynamically. We prove that via a
combination of static and dyna