Explicit Splice Imports - #412
Conversation
|
I talked to @mpickering and I support this. I think this proposal is insufficient to ensure the correct native vs foreign versions of packages is always used for cross compilation, but also that that's fine. This indeed unblocks better parallelism in the presence of TH, and lays a foundation both in terms of the implementation and GHC user mind-share for something like my #243 to do the rest for cross. That proposal, with its GHC and Cabal portions, had proved to much for people to digest all at once, and likewise to much for me as author assure every morsel was completely baked, so I think approving a design incrementally like this is not only good, but necessary. |
|
I'm in strong support of this directional of travel, but have a number of nitpicks/clarifying questions:
import A ( x )
import splice B ( x )
foo = $( x ) xIs that allowed? It's unambiguous, but perhaps confusing. I think I would prefer names from both bare
Thanks -- this is such a nice simplification over #243, and I think it will be much easier to implement and understand. |
I would prefer not? It was my understanding that
So once we get to supporting cross, e.g.
Good catch. I agree.
For the record, it is.
I'm fine landing this as a first step, but I highly doubt we're going to be able to avoid the extra stuff from #243 and I think it's important that we agree on the requirements long term, even if we just deciding on this for now. For cross, this proposal solves the following: module C where
import splice B (bar)
x = $(bar) -- foo is from the native platform's AHowever, it doesn't help us write module B
import {- ?? -} A (foo)
bar = [| foo |] -- what platform?If we follow the evaluation: x = $(bar) = $([| foo |]) = foowe see that That means we at least need Finally, if one wants to support |
|
Thanks for that example @Ericson2314, I now understand what you were staying about cross-compilation on IRC. Are there not deeper problems here though? If you In fact, I think the whole idea cross-stage persistence for top-level functions is built under the assumption that host = target. I think it's an open research design question about how it should work for heterogeneous cases. For example, you wouldn't expect CSP to work when generating a C program from a Haskell program. |
Done, I mean both.
Done
Clarified.
I choose (at random) before.
I have no opinion about this. There are two things which make this awkward. 1. The extension is called
At least a warning would be expected I think.
This is a good question. I think that is a similar question to your one about ambiguity. You definitely need to splice import a module if it's instance is used in a top-level splice, but whether you allow different instances to be in scope in splices as the rest of the module is a choice of design. It seems like it would be less confusing to insist on the same instances in scope in both places but there's no reason from an implementation perspective.
That is disallowed.
Done
|
|
Glad it's clear now, @mpickering.
Yes I agree, but conversely.
I think it's just sufficient to not have cross stage persistence. The Racket way should work just fine, and the way build time dependencies work in Nixpkgs is analogous to Racket. I would say the research is done. |
| subtract the number of splices. | ||
|
|
||
| top-level splice | ||
| A splice, where the body is at a negative level or a top-level unadorned |
There was a problem hiding this comment.
I'm confused by "or" here. It seems that the first set includes the second, making the "or" part redundant. Or am I missing something?
There was a problem hiding this comment.
I don't know how to describe a declaration splice which is not explicitly spliced. For example
module M where
import Lens
data A ...
deriveLens 'A
The call to deriveLens 'A should be understood to be at a negative level (hence deriveLens should be splice imported) but it isn't surrounded by splices.
|
I don't see any text in the updated proposal clarifying the ambiguity questions around e.g. Good point about instances -- I hadn't thought about those. Let's examine this: module X where
data X = MkX
module Normal where
import X
instance Show X where show _ = "normal"
module Splice where
import X
instance Show X where show _ = "splice"
module Bottom where
import X (X(..))
import splice X (X(..))
import Normal ()
import splice Splice ()
import splice Language.Haskell.TH.Lib ( stringE )
s1 = show MkX
s2 = $( stringE (show MkX) )Should this be accepted? If we allow different sets of instances to be in scope in different stages, then "yes", with |
|
FWIW in the cross case, @goldfirere's example is fine, even necessary. E.g. module X where
data X = MkX
module Instance where
import X
#if aarch64_HOST_OS
instance Show X where show _ = "are you reading this on a phone?"
#else
instance Show X where show _ = "are you reading this on a desktop?"
#endif
module Bottom where
import X (X(..))
import splice X (X(..))
import Instance ()
import splice Instance ()
import splice Language.Haskell.TH.Lib ( stringE )
s1 = show MkX
s2 = $( stringE (show MkX) )It's only when we wish to "observe" build == host and "unlock" features like cross stage persistence that we have new coherence obligations across stages. |
|
I think the conservative position here is to disallow all these examples of ambiguity we have been discussing. If in the future like @Ericson2314 suggests we want to relax the requirements, then it won't break any user code to make the check less stringent.
Positive levels ensure that code generation doesn't depend on information which is not yet available. For example, positive levels |
|
I updated the proposal now with comments about ambiguity. |
|
@mpickering That's fair, but if we are trying to be conservative we should probably straight up ban cross stage persistence too? |
|
@Ericson2314 Cross-stage persistence is to do with promoting identifiers upwards in levels, this proposal is only about splices, not about quotes. |
|
@mpickering well what about local definitions using splice imports that are then spliced? That is "morally OK" provided those definitions are used at runtime or exported, but doesn't that work today as cross-stage persistence? (Maybe this comes with GHCi more than real code, I forget the ins and outs of today's staging restriction) |
|
An example would be useful? Do you mean.. There's no CSP there, x is used at the level it is bound. |
|
Nevermind, I think it's just ghci, which let me do > id' a = a
> $(id' [| 1 |]) |
|
Good stuff. Some thoughts
|
|
@simonpj Thanks for your comments. I have updated the proposal apart from your "strongly suggested change". Distinguishing between things which are only needed at build-time and things which are needed at runtime can further be exploited if we introduce the idea of build dependencies, which will not ultimately be linked into the final executable. I believe this is an important distinction to make and would prefer to keep the design as-is.
If you export a splice import in a situation like you describe, it turns from a build-time dependency into a runtime dependency. Splice imported things definitely won't exist at runtime (only the stuff they end up generating). Allowing a splice export would be similar to allowing: |
If this is an important distinction, perhaps you can list it under "Motivation"? I had no idea that this was one of your goals, and I don't think the proposal mentions it at all. When saying "Splice imports can't be re-exported, unless they are also imported normally", link back to that particular motivation, since the sole reason for that rule is that motivation. I'm sad to lose the idea that everything in the proposal can be explained by the existing stating mechanism. It seems so simple and elegant! Perhaps you could list this possibility under "alternatives" and explain why you chose not to adopt it. (I suppose it is possible that the committee might take a different view.) |
In fact, things can be explained in terms of levels still: |
|
@simonpj The "cumulative stages" you proposed run afowl of cross compilation too: the build-time version of something may be quite different than it's run-time counterpart. |
|
I'm in favor of the proposal's intent as written, and against @simonpj's strongly urged change. The key observation is that cross-stage persistence has costs. One particularly big cost is if the stages are actually executed on different architectures, where cross-stage persistence requires two separate binaries. But the idea of avoiding linking some dependencies in the final executable is a nice example of a benefit of avoiding cross-stage persistence. Note that, even between levels 0 and 1, there is a cost to persistence in the form of |
I am open to being persuaded. But the proposal does not present these arguments. Could it, please? |
|
I'm generally in support.
I can see the motivation, but I have lost a clear picture of how much implementation complexity this will impose. |
|
@simonpj I am hoping we can make each level it's own virtual module, as far as the implementation is concered. So as opposed to trying hard to keep things separate, we are separate by default. The challenge then because the interactions between names, e.g. quotes in stage |
|
I started implementing the proposal and it's not clear to me now how splice imports should work. The problem is that if I splice import an identifier then, the result of running the splice can also contain identifiers which come from splice imported modules. Therefore we would need to link against the module where the splice import came from. For example then Two ways to solve this:
I think I prefer option 1. |
|
This is why we need quote imports. If you can only use splice imports and splices and quote imports and in quotes, everything cancels out nicely, the phase separation is proper, and the driver has all the information it needs. |
|
A splice import means we must compile the import all the way to the end just to compile the current module half way (Tc). A quote import means we need only compile the import half way even if we are compiling the current module all the way to the end. But it crates a transitive dependency so quote import + splice import = (transitive) regular import, and then quote import gets forced the rest of the way. |
That does seem inevitable, but is quite a disappointing conclusion, given the goal of this proposal to cleanly separate what is needed at compile time from runtime. Suppose we have If a module Let's call So a splice-import of This makes my head spin a bit, but maybe there is something here. |
|
I don't really have an opinion on this matter myself. But I find myself wondering: what does Racket do about this? I imagine that the answer to this question is likely to inform the solution to @mpickering 's conundrum. |
|
@aspiwack the racket solution is the "quote import", see https://docs.racket-lang.org/reference/require.html?q=require and the |
|
The phase-offsetting when working with transitive dependencies, behind being how Racket works, is also what we did with Nixpkgs. As far is I know, it's the one and only way to preserve the proper separation of phases across module boundaries. |
No doubt further refining "compile-time" and "run-time" to specific compiler phases as outlined in #412 (comment) will make the head-spinning worse. But I do think it's a really good way to have the implementation and design police each other. |
|
I don't see it. That is, I don't see why module A where
data X = MkXmodule B where
import A
q = [| MkX |]module C where
import splice B
import A () -- this is the key line
mkx = $qThe "key line" above says that module Z where
z = 5in some module does two things: it creates an entity with original name Is this too complicated? Maybe. It does seem, to me, somewhat simpler than quote-imports. |
|
@goldfirere If I understand you correctly, your design means that if I feel like we should just do the Racket thing -- TH's biggest problem is not any one technical issue, I would say, but rather the fact that it is only in partial dialogue other langauge's macro efforts, especially as macros are more important and more studied over there than over here. I am excited about this sort of thing as a way to repair that conversation as much as I am about it fixing any particular issue. At the very least, if we don't want to do the Racket way, I would like very detailed reasoning on why. |
|
I do broadly think that "Just do what Racket does" is a very good default position on macro systems. That said, Racket has a serious module system and Haskell doesn't, so the situations are not entirely identical. |
|
Seems like there are some design questions to be resolved. Do we want quote imports or not? Assigning back for revision until the proposal takes a concrete stance on this. Hopefully the implementation efforts will also yield further insights. |
|
How I understand "the racket way" is like this: Quote and splice imports express relative offsets for the current module.
When the module is compiled, we ask to compile it at a specific level, usually 0. This then fixes the level all the imports are required it, which fixes the level all it's imports are required at and so on. The result is a build graph which dictates at which levels all the modules must be compiled at. In a future where GHC is multi-target, then each level can be for a different target. CSPCross-stage persistence adds the assumption that if a module is available at n then it is available at n + 1. Therefore an import which is implicitly persisted using top-level CSP adds a dependency on both Current RestrictionsAt the moment GHC is not multi-target in the sense that we can switch generating code for different architectures at runtime. There are however two different modes which can be configured at runtime.
It only makes sense to use -fno-code at the last stage, as you wouldn't be able to execute any of the prior stages if you don't generate any code. So if you set the top-level target to be compiled with ExampleCompiling the top module with |
In my head at least, this proposal is now back with the author for revision, in the light of this discovery. It's not easy to see a way through here. I agree that Racket is an excellent place to look. |
Is incompatible with cross compilation, so I rather have a design that doesn't assume/require it so we can add knobs for that later.
Just a nit, since your |
|
To add on to @mpickering's good example, with more fine-grained scheduling, I would hope to see something like: |
|
Yeah, I agree that:
|
|
I'm all for starting with what Racket does, but we should at all times question whether we can make it simpler or need to do it differently due to our use case (that is: metaprogramming and not module systems). So I'm all for sketching out how Racket-style quote imports and splice imports should work in Template Haskell. We should really figure out how cross-stage persistence interacts with module Def where
n :: Int
n = 0{-# LANGUAGE TemplateHaskell #-}
module Use where
import Def
import Language.Haskell.TH.Syntax
laterN :: Q Exp
laterN = [| n |]
nowN :: Q Exp
nowN = [| $(lift n) |]
-- = lift nThe bindings So Thus, implicit use of That would also allow us to understand To conclude, I don't think we need to ban cross-stage persistance/ {-# LANGUAGE TemplateHaskell #-}
module OtherUse where
import splice Def
import Language.Haskell.TH.Syntax
copy :: Int
copy = $(lift n)
-- expands to `copy = I# 0#` |
|
I have a feeling that the But the appeal of this extension is perhaps to make the information evident in the module header, so that a static build plan can be constructed that says "compile module A for the target, module B for the host and module C for both" à priori, just by looking at the header and knowing what architecture the ultimate executable should run on. This is a bit simpler than starting to rename module C, which would then need to be interrupted upon seeing Regardless, I would like to see an argument for explicit annotations vs. inference in the proposal! I thought that another reason is assurance for the user that module A is only compiled for the host and module C is only compiled for the target. But alas, I don't think that is possible to state, because it depends on how modules A and C are imported (perhaps by quote or by splice themselves), not on whether the modules import by splice or by quote. So yeah, perhaps simply inferring quote and splice imports and persisting this information in the interface file would be a viable alternative to this proposal, and would perhaps fix https://gitlab.haskell.org/ghc/ghc/-/issues/14095 as well. It would like to discuss this and clarify the issue in the Alternatives section. Furthermore, I was recently made aware of the following staged metaprogramming system for OCaml: https://dl.acm.org/doi/pdf/10.1145/3607851 it would be good to verify that their notion of quote and splice imports does not deviate too much from ours without good reason. |
|
An observation that came up in the ecosystem workshop. HLS currently has an awful hack around TH. Notably, it has to compile any module dependencies which are needed for splice evaluation to bytecode, but it really wants to compile only those modules. Today, it's not possible to know which modules are necessary for splice evaluation until you actually typecheck the module. So HLS uses However, if we had explicit splice imports we could get this information trivially from looking at the module header. |
|
I think we can consider this proposal superseded by #682, which is effectively a revised version. Thanks to @mpickering for your work on this and to everyone who commented, |
This proposal suggest a refinement of the import syntax to distinguish between imports which can and can't be used in top-level splices.
The gist is that an import of the form
can only be used in top-level splices, and other imports can only be used outside of top-level splices.
Rendered