Skip to content

Take no .init of the inspected type in traits - #11119

Open
ehudt-weka wants to merge 1 commit into
dlang:masterfrom
ehudt-weka:no-init-in-traits
Open

ehudt-weka wants to merge 1 commit into
dlang:masterfrom
ehudt-weka:no-init-in-traits

Conversation

@ehudt-weka

@ehudt-weka ehudt-weka commented Oct 17, 2026 •

Copy link
Copy Markdown

Rationale

For a struct or a static array, an expression T.init makes the compiler build the type's
whole default-initializer literal, with one element expression per static-array element. Many
traits write R.init.front, S.init.opAssign(...) or foreach (e; T.init) only to get an
expression of type T, so they cost memory and time in proportion to the size of T. On a
struct with a ubyte[1 << 20] field, the range primitives and a dozen std.traits queries
take 0.29 s and 329 MiB, against 0.09 s and 65 MiB with this PR. map, minElement,
appender and others pass the same costs on to user code through their constraints.

lvalueOf!T and rvalueOf!T from std.traits are declared, never-defined functions that
give an expression of type T without building a literal. This PR uses them wherever .init
of the inspected type served only that purpose:

  • std.range.primitives: isInputRange, isForwardRange, isBidirectionalRange,
    hasSlicing, ElementType (and through it ElementEncodingType and the other traits built
    on it).
  • std.traits: CommonType, isIterable, ForeachType, hasElaborateAssign,
    hasNested, fullyQualifiedName (static arrays), ifTestable, and the alias-this lookup in
    BooleanTypeOf, IntegralTypeOf, FloatingPointTypeOf, CharTypeOf,
    StaticArrayTypeOf, AssocArrayTypeOf and BuiltinTypeOf.
  • std.algorithm: the constraints and checks of map, substitute and
    minElement/maxElement (extremum).
  • std.array: Appender's canPutRange and canPutConstRange.
  • std.typecons: isTuple, areCompatibleTuples, Tuple._Tuple_super, Rebindable,
    Rebindable2, and apply.

As asked in review, this PR keeps the answers of these traits, with one exception, below. The
.init uses whose replacement changes answers are in #11143, stacked on this one.

The non-obvious choices:

  • isInputRange and ElementType also require is(typeof(R.sizeof)). R.init used to lay
    the struct out first. Without that step, a range trait evaluated while the struct's own
    declaration is still being analysed infers the struct's member functions as impure and
    @system. One example is a trait in a member's return type, as in Chunks.opSlice, which
    returns typeof(takeExactly(this, 0)). In that case sequence!"..."(1, 2).chunks(2) stops
    compiling in @safe pure code. A new unittest covers this case.
  • ElementType and isBidirectionalRange still take the .init of the element type
    (lvalueOf!R.front.init), and ElementType keeps R.init.front.init for an enum. An
    enum's .init is its first member, not a new literal, and only that expression gives an
    enum whose base type is an array a front. Replacing either changes answers, so both are in
    Take no .init of the element type in ElementType, nor of the range in each #11143.
  • isIterable and ForeachType try foreach over *p with T* p = null first, then over
    rvalueOf!T. An opApply that takes a @safe delegate refuses both lvalueOf!T and
    rvalueOf!T: in a function literal whose attributes are inferred, the call to the bodyless
    helper makes the loop body's delegate @system. A non-copyable range needs the rvalue,
    because foreach over *p would copy it.
  • CommonType switches only struct and union operands. Those are where the large literals
    are. For other types, .init is a literal such as null, [] or 0, and replacing it
    changes answers that rely on literal conversions.
  • ifTestable keeps T.init for types other than structs and unions, for the same reason:
    ifTestable!(int, (ubyte a) => a) must stay true by value range propagation. For a struct
    or union it tests pred(rvalueOf!T). When that does not compile, it tests an rvalue of each
    qualified form of Unqual!T (mutable, const, immutable, shared, shared const). The
    literal T.init is unique, so it converted to any of them, and master answered true when
    pred took any one.
  • isTuple first checks is(Unqual!T == Tuple!Specs, Specs...). The literal T.init of a
    qualified tuple with indirections, such as const Tuple!(int[], int), converted to the
    mutable Tuple!Specs parameter of the probe function as a unique expression; rvalueOf!T
    does not.

Changed answers

A differential check printed the answer of every changed trait, and whether each changed
function accepts the type, for 629 types: 138 types in up to 5 qualifier variants each. The
types include structs with and without indirections, destructors, postblits, copy
constructors, disabled copy, default construction and assignment, alias this to every builtin
kind, unions, enums with every kind of base type, classes, tuples, Nullable, arrays of
structs, and ranges with field, method, property and ref primitives. Of 64,860 answers, 559
differ between master and this PR, in two groups:

  1. Types that declare a member named init (554 answers, 6 types in 5 qualifier variants).
    There, T.init named that member, so the traits answered for the member, not the type.
    For example, a range type with a member function init was not an input range on master,
    and is one now. Keeping master's answers would need a guard for such types at about 30
    places, so this PR does not, and the changelog entry documents the change. A new unittest
    covers it; dmd accepts such a member declared directly, and the test declares it through a
    mixin template only because D-Scanner flags a member named init.
  2. For a shared const struct with alias this to a static array, master's answers depend on
    which queries on the same type came first (5 answers). After the other queries in the
    check, ElementType and ElementEncodingType are void, isIterable is false,
    ForeachType is an error, and each refuses the type. Queried alone, each gives the same
    answer as this PR: shared(const(int)), true, and each accepts it.

An earlier revision of this description reported 101 of 50,550 answers changed. That run of
the check stopped at dmd's default limit of 20 errors, so about 20 types were never compared.
This run used -verrors=0.

Measurements

The same x86_64 dmd (7023163b4) and the same druntime imports were used for both trees
(macOS, -o- unless noted, peak RSS from /usr/bin/time -l, 3 runs each, median time).
These numbers replace the ones in the earlier description, which I could not reproduce for
the Phobos queries alone.

Large types. The range primitives, CommonType, isIterable, ForeachType,
fullyQualifiedName, hasNested, ifTestable, IntegralTypeOf and isTuple were queried
on structs holding a ubyte[N], and on a bidirectional range holding one:

N master this PR
2^16 0.10 s, 65 MiB 0.08 s, 49 MiB
2^20 0.29 s, 329 MiB 0.09 s, 65 MiB
2^22 1.34 s, 1135–1168 MiB 0.12 s, 118 MiB

Most of what still grows with N here is ElementType and isBidirectionalRange on Huge[],
which take the .init of the element type; with #11143 the last row is 0.10 s, 54 MiB.

Functions on such a struct (N = 2^22), one function per compile:

Code master this PR
a.map!(e => e.x).sum 0.11 s, 137 MiB 0.09 s, 105 MiB
a.each!((ref e) => e.x++) on a static array 0.14 s, 168 MiB 0.08 s, 40 MiB
minElement!(e => e.x) + maxElement!(e => e.x) 1.93 s, 1014–1047 MiB 0.13 s, 234 MiB
appender!(Huge[]) put of an element and of a slice 2.85 s, 765–771 MiB 0.10 s, 84 MiB
Nullable!Huge.apply 0.13 s, 107 MiB 0.13 s, 107 MiB
Rebindable!(const Huge) 0.09 s, 105 MiB 0.08 s, 41 MiB
sum over the range 0.16 s, 232 MiB 0.07 s, 40 MiB

Nullable builds DontCallDestructorT.init as a field initializer, which costs the same on
both trees. The rest of what remains comes from ElementType (see above) and, for
minElement/maxElement, from function bodies, which I have not traced.

Ordinary code. Peak RSS rises slightly. Time does not change beyond run-to-run noise.

  • Phobos (dmd -c -i=std -preview=dip1000 std/package.d, the dmd perf-check workload):
    580–583 MiB before, 587–588 MiB after (+0.9%).
  • ctRegex unittests (std/regex/internal/tests.d): 2053 MiB before, 2052–2056 MiB after.
  • -unittest -o- of 16 Phobos modules: +0.5% (std.regex) to +2.6%
    (std.algorithm.iteration), median +1.2%. Four of these modules also gain this PR's new
    unittests.

The increase comes from template instances: lvalueOf!R and rvalueOf!R are one instance
per inspected type, which costs more than R.init when R is small.

Testing

  • New unittests:
    • chunks of sequence in a @safe pure unittest. This guards the R.sizeof step above:
      it passes on master, and fails on this PR without R.sizeof.
    • The range traits on a range with no @property primitive.
    • isIterable and ForeachType on an opApply with a ref @safe delegate and on a
      non-copyable range.
    • isTuple on qualified tuples with indirections.
    • isInputRange and isForwardRange on a range type with a member function init
      (fails on master).
  • make unittest (release, x86_64) on macOS, where dmd runs under Rosetta 2, at this
    revision and at master a17028b, both against the druntime of dmd 7023163b4: 119 modules
    pass and 10 fail, the same modules on both, and all touched modules pass. Nine failures are
    in floating-point code, which goes wrong under Rosetta's x87 emulation (std.math.*, std.mathspecial,
    std.numeric, std.internal.math.*, std.format.internal.floats, std.conv); std.path
    runs out of memory. For the same reason the unittests of std.format.internal.write and
    std.math.exponential, .operations and .traits do not compile in this environment, on
    master too; those four were built without -unittest.
  • The differential check described above.

Pre-review checklist

  • I have performed a self-review of my code.
  • If my PR fixes a bug or introduces a new feature, I have added thorough tests.
  • If my changes are non-trivial and do not concern a reported issue, I have added a changelog entry.

LLM/AI disclosure

This PR was written entirely by an LLM/AI.

An AI coding agent (Claude, by Anthropic) wrote the code, the tests and this description, ran
the checks and measurements in it, and opened this PR at @ehudt-weka's request, from that
account; "I" above is the agent. The self-review checked above is the agent's own (the tests,
the differential check and the measurements), not a human code review.

@thewilsonator

Copy link
Copy Markdown
Contributor

Thanks!

Comment thread changelog/traits_no_init.dd Outdated
of type `T`. They now use $(REF lvalueOf, std, traits) and $(REF rvalueOf, std, traits),
so their compile-time memory and time no longer grow with the size of `T`.

Answers are unchanged, except in these cases:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

could you split these cases that change out into a separate PR so that is obvious as to what they are? Also that would enable us to merge the cases that don't change much sooner.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done. The cases that change answers are now in #11143, stacked on this PR: each with a ref parameter on a forward range, ElementType of an enum whose base type is an array, and element types with a member named init.

This PR keeps every answer except for a type that declares a member named init. There T.init named the member, so the traits answered for the member instead of the type. Keeping those answers would need a guard at about 30 places, so the changelog documents the change instead and a new unittest covers it. I can add the guards if you prefer.

While splitting I found that the earlier comparison had stopped at dmd's 20-error limit; the description now gives the full numbers.

For a struct or a static array, `T.init` makes the compiler build the
type's whole default-initializer literal, one entry per static-array
element. The range primitives traits, std.traits (CommonType, isIterable,
ForeachType, hasElaborateAssign, the *TypeOf alias-this traits, ...), and
constraints in std.algorithm, std.array and std.typecons used it only to
form an expression of type `T`. They now use lvalueOf and rvalueOf, so
their compile-time memory and time no longer grow with the size of `T`.

CommonType keeps `.init` for operands that are not a struct or union,
since null and array literals merge differently in `?:` than values of
the type do.

Answers are unchanged, except for a type that declares a member named
`init`: `T.init` named that member, so the traits answered for the member
instead of the type.
@jmdavis

jmdavis commented Oct 20, 2026

Copy link
Copy Markdown
Member

So, this is supposed to help speed up compilation, because it avoids needing to calculate T.init? How can that be the case when T.init always has to be calculated? Is the compiler recalculating that information multiple times for some reason instead of reusing that information? My gut reaction here is that it would be better to fix the compiler rather than add a bunch of extra template instantiations. What am I missing here that makes this the better option?

@ehudt-weka

Copy link
Copy Markdown
Author

T.init is not calculated once per type. Each T.init expression makes dmd build a new literal: getProperty calls defaultInitLiteral (dmd/typesem.d), which builds a StructLiteralExp with one expression per field, recursively, and for a static array an ArrayLiteralExp with one element slot per array element. Nothing caches the result. So a struct holding a ubyte[1 << 20] costs a 2^20-slot array (8 MiB) for every R.init a trait writes, and one type passes through dozens of such traits. In these traits T.init only ever appears inside typeof or is, so the literal is built only for its type and then dropped.

To see how much the compiler could avoid, I prototyped rikkimax's suggestion from #11143: dmd skips the literal when X.init is the whole operand of typeof. It changes no answers, but it covers only 2 of the 49 uses this PR replaces. The rest use T.init as a value (R.init.front, foreach (e; T.init), a function argument), where dmd needs the literal itself, because constant folding, CTFE and implicit conversion checks read its elements. With that dmd and master Phobos, the traits on a struct holding a ubyte[1 << 22] still peak at 1040 MiB, against 118 MiB with this PR. Avoiding the cost in those uses would take a static-array literal without one slot per element, which is a much larger change to dmd. Until then, lvalueOf and rvalueOf are what Phobos already uses to get an expression of a type (43 places on master), and the druntime half of this change was merged as dlang/dmd#24072.

The extra template instances do cost something, and the description measures it: compiling Phobos (the dmd perf-check workload) peaks 0.9% higher in memory, with no change in time. On a struct holding a ubyte[1 << 20], the traits go from 0.29 s and 329 MiB to 0.09 s and 65 MiB.

ehudt-weka added a commit to ehudt-weka/phobos that referenced this pull request Oct 21, 2026
…683364

Brings in the review fixes of dlang#11119 and dlang#11143.

`ifTestable` on a struct or union: when `pred` does not take a `T`, try an
rvalue of each other qualifier of `T`, since the unique literal `T.init`
converted to all of them. A struct with indirections and a `const` or
`immutable` predicate parameter is testable again, as before this change.

Tests for `isIterable`/`ForeachType` (`ref @safe` opApply, non-copyable
range), the range traits without `@property`, a member named `init`, an
enum based on an array, `each` with a `ref` parameter on a field `front`,
`Chunks` over an infinite range in `@safe pure` code, and qualified
`isTuple`. Comments reworded.
@jmdavis

jmdavis commented Oct 21, 2026

Copy link
Copy Markdown
Member

T.init is not calculated once per type. Each T.init expression makes dmd build a new literal: getProperty calls defaultInitLiteral (dmd/typesem.d), which builds a StructLiteralExp with one expression per field, recursively, and for a static array an ArrayLiteralExp with one element slot per array element. Nothing caches the result. So a struct holding a ubyte[1 << 20] costs a 2^20-slot array (8 MiB) for every R.init a trait writes, and one type passes through dozens of such traits. In these traits T.init only ever appears inside typeof or is, so the literal is built only for its type and then dropped.

The the compiler should probably be changed to cache the result. T.init is used heavily it D's metaprogramming, and if it's a performance problem, we should fix it at the source. Add yet more template instantiations to work around the problem is going to make the code harder to read and increase the number of template instantiations at a time when Walter has already been complaining about how many templates Phobos instantiates.

I really don't think that this is a desirable direction to go in. Maybe it makes sense for some stuff in Phobos in the short term if the performance difference is large enough, but this is something that affects far more than Phobos code, and I really don't think that we want to start telling everyone to use rvalueOf!T instead of T.init everywhere just to work around an implementation issue of the compiler.

@Herringway

Copy link
Copy Markdown
Contributor

I really don't think that this is a desirable direction to go in. Maybe it makes sense for some stuff in Phobos in the short term if the performance difference is large enough, but this is something that affects far more than Phobos code, and I really don't think that we want to start telling everyone to use rvalueOf!T instead of T.init everywhere just to work around an implementation issue of the compiler.

Any solution that requires solving D's technical debt is not a feasible one. Work around it and move on.

@jmdavis

jmdavis commented Oct 21, 2026

Copy link
Copy Markdown
Member

I really don't think that this is a desirable direction to go in. Maybe it makes sense for some stuff in Phobos in the short term if the performance difference is large enough, but this is something that affects far more than Phobos code, and I really don't think that we want to start telling everyone to use rvalueOf!T instead of T.init everywhere just to work around an implementation issue of the compiler.

Any solution that requires solving D's technical debt is not a feasible one. Work around it and move on.

I do not buy that, and a big part of the problem is this is not the sort of thing where you move on. If the problem is big enough to solve here, then it's big enough that it needs to be solved generally, and if we have to work around the compiler, that means that code in general is going to need to use rvalueOf!T instead of T.init, which means instantiating yet more templates all over the place just to work around the compiler's issues. It should not be the case that we cannot improve the compiler.

@rikkimax

Copy link
Copy Markdown
Contributor

Any solution that requires solving D's technical debt is not a feasible one. Work around it and move on.

I do not buy that, and a big part of the problem is this is not the sort of thing where you move on. If the problem is big enough to solve here, then it's big enough that it needs to be solved generally, and if we have to work around the compiler, that means that code in general is going to need to use rvalueOf!T instead of T.init, which means instantiating yet more templates all over the place just to work around the compiler's issues. It should not be the case that we cannot improve the compiler.

Agreed.

This is solvable; solve it properly.

@Herringway

Copy link
Copy Markdown
Contributor

Any solution that requires solving D's technical debt is not a feasible one. Work around it and move on.

I do not buy that, and a big part of the problem is this is not the sort of thing where you move on. If the problem is big enough to solve here, then it's big enough that it needs to be solved generally, and if we have to work around the compiler, that means that code in general is going to need to use rvalueOf!T instead of T.init, which means instantiating yet more templates all over the place just to work around the compiler's issues. It should not be the case that we cannot improve the compiler.

Agreed.

This is solvable; solve it properly.

Moving on is how it will be solved. The same way "instantiating lots of templates is bad" was solved.

Feel free to prove me wrong, though!

@ehudt-weka

Copy link
Copy Markdown
Author

I tried caching. Caching whole literals runs into CTFE and the optimizer changing literal nodes in place, but the costly part can be shared: every element slot of a static array's default-init literal is null (it stands for basis), so all such literals of one length can use one slot array. That is a small dmd change (41 lines). It gives the same output as stock dmd on the trait comparison, the dmd test suite, and the Phobos object files (byte-identical). It also corrects what I wrote above: most of the memory can be saved without a new kind of literal.

Peak memory and time on a struct holding ubyte[1 << 22]:

stock dmd caching dmd
traits, master 1168 MiB, 0.87 s 204 MiB, 0.64 s
traits, this PR 118 MiB, 0.10 s 84 MiB, 0.09 s
minElement/maxElement, master 1050 MiB, 1.70 s 440 MiB, 1.54 s
minElement/maxElement, this PR 234 MiB, 0.11 s 73 MiB, 0.07 s
appender.put, master 788 MiB, 2.25 s 533 MiB, 2.20 s
appender.put, this PR 84 MiB, 0.09 s 83 MiB, 0.09 s

So I'd like to open the cache as a dmd PR; it helps every T.init, not only Phobos. It doesn't replace this PR, though. Where T.init is used as a value, dmd still walks every element (the time column). And with stock dmd, suppressed error messages render the whole literal into memory that is never freed. Neither goes away while the literal is built at all, which is what lvalueOf avoids here.

@rikkimax

Copy link
Copy Markdown
Contributor

That is still a decent decrease, and a good example of why we are pushing for optimizing dmd first.

If done sufficiently, this PR should decrease in size.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Review:AI Generated Code that is generated by an LLM AI — or suspected to be. Severity:Enhancement std.traits

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants