Skip to content

Harden Period creation and use of Run/PhaseId - #25484

Merged
SolalPirelli merged 9 commits into
scala:mainfrom
dotty-staging:solal/harden-period
Mar 30, 2026
Merged

Harden Period creation and use of Run/PhaseId#25484
SolalPirelli merged 9 commits into
scala:mainfrom
dotty-staging:solal/harden-period

Conversation

@SolalPirelli

Copy link
Copy Markdown
Contributor

Found while investigating #24970 but unfortunately does not fix that problem.

How much have your relied on LLM-based tools in this contribution?

not

How was the solution tested?

existing tests

if typerPhase.period == Periods.InvalidPeriod then
getMember(owner, innerName.toTypeName)
else
atPhase(typerPhase)(getMember(owner, innerName.toTypeName))

@SolalPirelli SolalPirelli Mar 10, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

⚠ïļ bugfix, this is not great, but it's better than doing atPhase with an uninitialized phase.

*/
def initial: SingleDenotation =
if (validFor.firstPhaseId <= 1) this
if (validFor.firstPhaseId == FirstPhaseId) this

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

cannot be == 0 here unless something has gone terribly wrong

*/
def skipRemoved(using Context): SingleDenotation =
if (validFor.code <= 0) nextDefined else this
if (validFor == Nowhere) nextDefined else this

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

code is nonnegative

end goBack

if valid.code <= 0 then
if valid == Nowhere then

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

code is nonnegative

* last phase id: 7 bits
* #phases before last: 7 bits
*
* // Dmitry: sign == 0 isn't actually always true, in some cases phaseId == -1 is used for shifts, that easily creates code < 0

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

no longer true

case InitialPeriod => "InitialPeriod"
case InvalidPeriod => "InvalidPeriod"
case Period(NoRunId, 0, PhaseMask) => s"Period(NoRunId.all)"
case Period(runId, 0, PhaseMask) => s"Period($runId.all)"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

this + the next few diffs: no reason to use 0 as the first phase ID to say all when we already have a constant defined to mean that

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

also, PhaseMask is logically the wrong thing here, even though it's equivalent to MaxPossiblePhaseId

}
}

inline val NowhereCode = 0

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

moved down so earlier vals don't depend on later ones

import scala.collection.mutable.ListBuffer
import dotty.tools.dotc.transform.MegaPhase.*
import dotty.tools.dotc.transform.*
import Periods.*

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

duplicate import with line 5

&& ctx.phaseId <= denot.validFor.lastPhaseId
&& lastDenot != null
&& lastDenot.validFor.lastPhaseId > denot.validFor.firstPhaseId
&& denot.validFor.firstPhaseId < lastDenot.validFor.lastPhaseId

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

⚠ïļ bugfix, plus rewrite of the expr to be consistent between the checks: before this, we didn't have the second check, and CC would end up creating periods with an end phase before the start phase

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Consider adding a def containsPhaseIdNotFirst(id: PhaseId): Boolean in Period to perform the two first tests. This way it stays close to containsPhaseId and any optimization can be replicated to the other one.

Comment thread compiler/src/dotty/tools/dotc/Run.scala Outdated
given runContext[Dummy_so_its_a_def]: Context =
if myCtx eq null then myCtx = rootContext(using ictx)
assert(myCtx.nn.runId <= Periods.MaxPossibleRunId)
myCtx.nn

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

⚠ïļ bugfix, before this reset would leave the run in an invalid state since it sets myCtx to null but there was no code to re-initialize it (in practice we never actually reuse a run that we've reset so this didn't cause problems)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I assume this is not a perf-sensitive method?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I hope not, but also I don't really know why this assertion is there, it could probably just be deleted

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

ok so it is perf sensitive, I reverted this change... too bad

this.code < that.code

inline def >(that: Period): Boolean =
this.code > that.code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I didn't dare make these virtual methods in any way because of perf concerns, but I also don't think any code outside of Period should be accessing its .code

@SolalPirelli
SolalPirelli requested a review from sjrd March 10, 2026 17:57
@SolalPirelli
SolalPirelli marked this pull request as ready for review March 10, 2026 17:57
@SolalPirelli
SolalPirelli marked this pull request as draft March 11, 2026 14:42
@SolalPirelli

Copy link
Copy Markdown
Contributor Author

Found even more bugs, will put back in draft until those are fixed + I'm running benchmarks to make sure this doesn't kill perf

@@ -0,0 +1,40 @@
package dotty.tools.dotc.core

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

noticed there was a worksheet testing Period, so I ported it to actual tests

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

See also my comment on exhaustively testing Period.contains (obviously not on every CI run, but as a script that we can manually run when needed).

// lastDiff + d2 <= d1
// iff X == 0 && l1 - l2 >= 0 && l1 - l2 + d2 <= d1
// iff r1 == r2 & l1 >= l2 && l1 - d1 <= l2 - d2
// q.e.d

@SolalPirelli SolalPirelli Mar 11, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

⚠ïļ this proof was wrong, counter-example this = (1, 56, 1), that = (1, 51, 32), found by Z3 with:

(declare-fun r1 () (_ BitVec 17))
(declare-fun l1 () (_ BitVec 7))
(declare-fun d1 () (_ BitVec 7))

(declare-fun r2 () (_ BitVec 17))
(declare-fun l2 () (_ BitVec 7))
(declare-fun d2 () (_ BitVec 7))

; d <= l
(assert (bvule d1 l1))
(assert (bvule d2 l2))

; l < 64
(assert (bvult l1 #b1000000))
(assert (bvult l2 #b1000000))

; r > 0
(assert (bvult #b00000000000000000 r1))
(assert (bvult #b00000000000000000 r2))

(declare-fun code1 () (_ BitVec 32))
(assert (= code1 (concat #b0 r1 l1 d1)))

(declare-fun code2 () (_ BitVec 32))
(assert (= code2 (concat #b0 r2 l2 d2)))

(declare-fun phaseWidth () (_ BitVec 32))
(declare-fun phaseMask () (_ BitVec 32))
(assert (= phaseWidth #x00000007))
(assert (= phaseMask #x0000007F))

(declare-fun lastDiff () (_ BitVec 32))
(assert (= lastDiff (bvlshr (bvsub code1 code2) phaseWidth)))

(declare-fun result () Bool)
(assert (= result (bvsle (bvadd lastDiff (bvand code1 phaseMask)) (bvand code2 phaseMask))))

(declare-fun ideal () Bool)
(assert (= ideal (and (= r1 r2) (bvuge l1 l2) (bvule (bvsub l1 d1) (bvsub l2 d2)))))
(assert (not (= result ideal)))

(check-sat)
(get-model)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Does that mean you have a Z3 proof that the new code is correct? If yes, add it in a big comment somewhere, so we can retest it in the future if necessary?

def runId: RunId = code >>> (PhaseWidth * 2)

/** The phase identifier of this single-phase period. */
def phaseId: PhaseId = (code >>> PhaseWidth) & PhaseMask

@SolalPirelli SolalPirelli Mar 11, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

⚠ïļ this was used on periods with more than one phase... but in practice it seems it's used as a "first phase ID", so I modified callers to just use that

@SolalPirelli

Copy link
Copy Markdown
Contributor Author

This latest version is within 2% of main, on each side, i.e., sometimes a little faster and sometimes a little slower: https://lampepfl.github.io/scala3-benchmarks/#compare/3.8.4-RC1-bin-5c4fcc611d04bba02ef9d03645f4b60462c6fff3-BENCH,3.8.4-RC1-bin-877fc2cb695bffb63abe6302b58c6f0f7ecb29e2-BENCH

Given that main's code isn't actually correct, I think that's pretty good.

@SolalPirelli
SolalPirelli marked this pull request as ready for review March 13, 2026 13:13
def recordRecheckPhase(phase: Recheck): Unit =
val id = phase.id
assert(id < 64, s"Recheck phase with id $id outside permissible range 0..63")
assert(id < 64, s"Recheck phase with id $id outside range 0..63, cannot use Long bits encoding")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

just to make it clear this is an invariant of Phases, not of periods

@SolalPirelli
SolalPirelli force-pushed the solal/harden-period branch 2 times, most recently from d77df66 to a7308cb Compare March 18, 2026 08:07
// lastDiff + d2 <= d1
// iff X == 0 && l1 - l2 >= 0 && l1 - l2 + d2 <= d1
// iff r1 == r2 & l1 >= l2 && l1 - d1 <= l2 - d2
// q.e.d

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Does that mean you have a Z3 proof that the new code is correct? If yes, add it in a big comment somewhere, so we can retest it in the future if necessary?

private inline val NowhereCode = 0
final val Nowhere: Period = new Period(NowhereCode)
final val InitialPeriod: Period = Period(InitialRunId, FirstPhaseId)
final val InvalidPeriod: Period = Period(NoRunId, NoPhaseId)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What's the difference between Nowhere and InvalidPeriod? IIUC they both end up with code == 0.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good point. InvalidPeriod only had 3 uses so I replaced it with Nowhere.


/** The first phase of this period */
def firstPhaseId: Int = lastPhaseId - (code & PhaseMask)
def firstPhaseId: PhaseId = lastPhaseId - (code & PhaseMask)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could be done with one fewer operation:

((code >>> PhaseWidth) - code) & PhaseMask

(not sure it's really a desirable change, but if you were after raw perf numbers, it might)

def firstPhaseId: Int = lastPhaseId - (code & PhaseMask)
def firstPhaseId: PhaseId = lastPhaseId - (code & PhaseMask)

def containsPhaseId(id: PhaseId): Boolean = firstPhaseId <= id && id <= lastPhaseId

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Probably an actual perf improvement here. Use the property that if a <= b, then a <= x && x <= b is equivalent to (x - a) unsigned_<= (b - a). Here a == firstPhaseId == lastP - diff and b == lastP. So
(x - a) == x - (lastP - diff) == x + diff - lastP
(b - a) == lastP - (lastP - diff) == diff
Hence:

Suggested change
def containsPhaseId(id: PhaseId): Boolean = firstPhaseId <= id && id <= lastPhaseId
def containsPhaseId(id: PhaseId): Boolean =
val diff = code & PhaseMask
((id + diff - lastPhaseId) ^ Int.MinValue) <= (diff ^ Int.MinValue)

where (x ^ MinValue) <= (y ^ MinValue) is the encoding of x unsigned_<= y.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

very nice!

}
/** Does this period contain the given period? */
def contains(that: Period): Boolean =
// We want to check (run1 == run2) & (last1 >= last2) & (first1 <= first2).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Put this formula in the publicly visible Scaladoc?

// in the sign + run ID bits. If (run1 > run2) or (run2 < run1) or (run1 == run2 and last1 < last2),
// then (code1 - code2) is either large enough to have some run ID bits set, or negative so the sign bit is set.
((this.code - that.code) & (-1 << (PhaseWidth * 2))) == 0 &&
this.firstPhaseId <= that.firstPhaseId

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The second line is equivalent to that.firstPhaseId - this.firstPhaseId >= 0, which is equivalent to ((that.firstPhaseId - this.firstPhaseId) & SignBit) == 0.

So now you have (expr1 == 0) && (expr2 == 0), which is equivalent to (expr1 | expr2) == 0. Which means you can do everything with a single test:

(((this.code - that.code) & (-1 << (PhaseWidth * 2))) | ((that.firstPhaseId - this.firstPhaseId) & Int.MinValue)) == 0

I suggest exhaustively testing the formula we choose. We can limit choices of runId to two bits (from 0 to 3), and exhaustively test all possible values of lastPhaseId and diff. That's only 16 bits per operand, hence 32 bits of input. Testing the equivalence of two functions on 2^32 inputs can be done in a few minutes (ok maybe a few dozens of minutes, but definitely less than an hour).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

yeah it turns out I made a silly typo in one of my hex constants when originally writing this, and I'm wrong.

It's possible for run1 != run2 yet code1 - code2 has no run ID bits set and isn't negative either, e.g.:

  0 00000100100000001 0111110 0000000
- 0 00000100100000000 0111111 0000001
= 0 00000000000000000 1111110 1111111

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Ah yes. Carry gets canceled out.

I guess we can prevent that by sacrificing the bit n°14 (forcing it to always 0). We would have 1 fewer bit available to encode runIds, but then this would not happen anymore.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'll run benchmarks on this PR over the weekend, so we can decide if halving the number of run IDs is needed

&& ctx.phaseId <= denot.validFor.lastPhaseId
&& lastDenot != null
&& lastDenot.validFor.lastPhaseId > denot.validFor.firstPhaseId
&& denot.validFor.firstPhaseId < lastDenot.validFor.lastPhaseId

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Consider adding a def containsPhaseIdNotFirst(id: PhaseId): Boolean in Period to perform the two first tests. This way it stays close to containsPhaseId and any optimization can be replicated to the other one.

Comment thread compiler/src/dotty/tools/dotc/Run.scala Outdated
given runContext[Dummy_so_its_a_def]: Context =
if myCtx eq null then myCtx = rootContext(using ictx)
assert(myCtx.nn.runId <= Periods.MaxPossibleRunId)
myCtx.nn

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I assume this is not a perf-sensitive method?

@@ -0,0 +1,40 @@
package dotty.tools.dotc.core

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

See also my comment on exhaustively testing Period.contains (obviously not on every CI run, but as a script that we can manually run when needed).

def computeMemberTpe(): Type =
if (sym.is(Method)) sym.denot.info
else if sym.denot.validFor.phaseId > erasurePhase.id && sym.isField && sym.getter.exists then
else if sym.denot.validFor.firstPhaseId > erasurePhase.id && sym.isField && sym.getter.exists then

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Some .phaseIds become .firstPhaseId, and others became lastPhaseId. Elaborate?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

here "entered after erasure" in the comment right below so it makes sense to look at firstPhaseId I think

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I think my reasoning was "set to lastPhaseId which is cheaper, unless in context firstPhaseId makes more sense"

@som-snytt

Copy link
Copy Markdown
Contributor

As a performance footnote, I noticed 5f390be for inlined contains test.

@SolalPirelli

Copy link
Copy Markdown
Contributor Author

@som-snytt I don't think this is true anymore, a denot can have the period of a phase whose period has more than one phase because it's a list of mini-phases

final def phase: Phase = base.phases(period.firstPhaseId)
final def runId = period.runId
final def phaseId = period.phaseId
final def phaseId = period.firstPhaseId

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It feels wrong for this one to be lastPhaseId given the def phase using firstPhaseId just two lines above

@mbovel

mbovel commented Mar 23, 2026

Copy link
Copy Markdown
Member

Benchmarks started. Workflow run.

@mbovel

mbovel commented Mar 23, 2026

Copy link
Copy Markdown
Member

Benchmarks completed. Overview.

@SolalPirelli

Copy link
Copy Markdown
Contributor Author

@sjrd latest subset of benchmarks: https://lampepfl.github.io/scala3-benchmarks/#compare/3.8.4-RC1-bin-20260323-16cd4d9-NIGHTLY,3.8.4-RC1-bin-ad72f3091dc04606025b12b8e8a039094d97b22e-BENCH

Not sure if within noise or not. I could try the "steal a bit from run IDs" tactic, but maybe that's better left in a separate PR since at least it's correct now?

@SolalPirelli
SolalPirelli requested a review from sjrd March 24, 2026 08:00
@mbovel

mbovel commented Mar 24, 2026

Copy link
Copy Markdown
Member

Not sure if within noise or not.

If you're unsure, feel free to run them again; the results will aggregate automatically.


private inline val NowhereCode = 0
final val Nowhere: Period = new Period(NowhereCode)
final val InitialPeriod: Period = Period(InitialRunId, FirstPhaseId)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is not as good as one might think. Only primitive types can be inlined as inline vals. For custom value classes, it doesn't work.

If you don't need the stability, you can reclaim the earlier best code by making them inline defs instead.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

they're used in pattern matching so we need stability:

Error:  -- [E135] Type Error: /home/runner/work/scala3/scala3/compiler/src/dotty/tools/dotc/core/Periods.scala:170:15 
Error:  170 |          case Nowhere                                           => "Nowhere"
Error:      |               ^^^^^^^
Error:      |Stable identifier required, but dotty.tools.dotc.core.Periods.Nowhere found

that being said, they were already final val before, so this hasn't changed...?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

OK then leave it as before.

Comment thread compiler/src/dotty/tools/dotc/core/Periods.scala
@SolalPirelli
SolalPirelli marked this pull request as draft March 30, 2026 20:03
@SolalPirelli
SolalPirelli marked this pull request as ready for review March 30, 2026 20:03
@SolalPirelli
SolalPirelli merged commit 3da97ed into scala:main Mar 30, 2026
128 checks passed
@SolalPirelli
SolalPirelli deleted the solal/harden-period branch March 30, 2026 20:04
@WojciechMazur WojciechMazur added this to the 3.8.4 milestone Mar 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants