Skip to content

[BUG] Duplicate ATag error during sharing recovery with parallel GC #587

Open

Description

@JktuJQ

Environment

  • Accelerate 1.4.0.0
  • GHC 9.8.4 / base 4.19.2.0
  • Linux x86_64

Reproducing

Main.hs:

module Main where

import Control.Exception (evaluate)
import Control.Monad (forM_, when)
import qualified Data.Array.Accelerate as A
import qualified Data.Array.Accelerate.Trafo.Sharing as Sharing
import GHC.Conc (setNumCapabilities)

stage :: Int -> A.Acc (A.Vector Double) -> A.Acc (A.Vector Double)
stage n input = foldl1 (A.zipWith (+)) [A.map (\x -> x + A.constant (fromIntegral (n + i))) input | i <- [1 .. 8 :: Int]]

program :: Int -> A.Acc (A.Vector Double) -> A.Acc (A.Vector Double)
program n input = foldl (\x f -> f x) input (replicate 40 (stage n A.>-> stage (n + 1)))

main :: IO ()
main = do
  setNumCapabilities 8
  forM_ [1 .. 1000 :: Int] $ \n -> do
    _ <- evaluate (length (show (Sharing.convertAfun (program n))))
    when (n `mod` 100 == 0) (print n)

With Accelerate installed and available to GHC:

ghc -O1 -threaded -rtsopts -package accelerate Main.hs -o repro
./repro +RTS -A64k -RTS

Expected behavior

All 1000 graph conversions complete successfully.

Actual behavior

Sharing recovery fails with an internal error:

Encountered duplicate 'ATag's
  3116: Atag 1, 3100: Atag 1
CallStack (from HasCallStack):
  internalError: Data.Array.Accelerate.Trafo.Sharing:2243:19
  buildInitialEnvAcc: Data.Array.Accelerate.Trafo.Sharing:2623:48
  scopesAfun1: Data.Array.Accelerate.Trafo.Sharing:2346:62

The failing iteration varies between runs.

Disabling parallel GC for the same executable:

./repro +RTS -A64k -qg -RTS

I tested five runs in each mode using the same executable, alternating between the two modes:

GC mode Runs Duplicate ATag failures Successful runs
Parallel GC 5 3 2
Parallel GC disabled (-qg) 5 0 5

All five runs with -qg completed 1000 conversions each: 5000 conversions with no errors. In the parallel-GC runs, two failures occurred after the progress message 400; another occurred before the first progress message (100).

Suspected cause

Sharing recovery requires physically shared Atag nodes, as described in Sharing.hs.

GHC's parallel copying GC can duplicate immutable constructors through copy_tag_nolock.
This suggests that physical copies of the same tag receive different stable names and
are subsequently rejected as duplicate tags during sharing recovery.

Discussion

Would it be feasible to fix this by identifying bound variables independently of physical sharing,
or by handling duplicated tags during environment construction?

I’d be happy to try working on a fix with some guidance.
I’m not very familiar with de Bruijn indices or the sharing recovery implementation,
and much of the investigation above was carried out with assistance from LLM,
however, I would like to extend my knowledge in this pretty interesting subject,
so I’d appreciate a review of the diagnosis and advice on where to start.

Activity

  1. JktuJQ commented on Nov 3, 2026

    @JktuJQ
    Author

    Heh, funny timing - looks like I stumbled across this just before the GHC ticket was opened :)

    I reported it here because my understanding was that GHC doesn’t guarantee preservation of physical sharing, and the documented StableName guarantees seemed weaker than what sharing recovery relies on. So I wasn’t sure this should be considered a GHC bug, rather than an assumption Accelerate needs to account for.

    In the meantime, would it make sense to investigate an Accelerate-side fix, or would you advise to wait for a decision on the RTS behavior?

  2. luite commented on Nov 3, 2026

    @luite

    GHC hasn't really offered any guarantees around this (other than what's stated in the StableName docs), so I don't think it's a bug per se. But it is clearly something that quite a few packages rely on for this kind of observable sharing tasks (a quick search of hackage revealed)

    Another common failure mode (in packages on hackage) is a data constructor wrapping a foreign structure allocated with malloc and having a finalizer free. Duplicating the datacon could lead to the finalizer being run early and use-after-free of the underlying data.

    Overall I think it's serious enough to consider changing the GHC behaviour or contacting the maintainers of the packages...

  3. luite commented on Nov 3, 2026

    @luite

    How much of an inconvenience would it be for accelerate to fix sharing recovery with the current GHC behaviour, where objects may be duplicated by the GC?

  4. tomsmeding commented on Nov 3, 2026

    @tomsmeding
    Member

    Another common failure mode (in packages on hackage) is a data constructor wrapping a foreign structure allocated with malloc and having a finalizer free. Duplicating the datacon could lead to the finalizer being run early and use-after-free of the underlying data.

    I read this as "finalizers may run multiple times". Are you sure? If so, that sounds to me like a rather severe undocumented downside of finalizers that is likely to break almost all uses of them: freeing of external resources through ForeignPtr is kind of the prime use-case of them -- it's in the name!

    How much of an inconvenience would it be for accelerate to fix sharing recovery with the current GHC behaviour, where objects may be duplicated by the GC?

    Ultimately a 100% robust, fixed sharing recovery would need to work even in the case that stable names are always distinct, i.e. provide no information. That would be rather difficult, as sharing detection is the way that self-referential terms can be detected and reported as erroneous (e.g. let a = map (+1) a in ...). If you don't mind looping / exploding in that case (and it's probably fine since failure of stable name equality is likely probabilistic, i.e. succeeds eventually with probably 1), and furthermore don't mind that terms may have (work) duplication in them (where it's notable that Accelerate doesn't really have common subexpression elimination, because GHC normally does that), it may be fixable. However, I've never really understood the algorithm that does the Acc/Exp stratification, so I cannot say for sure.

    The other challenge is finding someone daring enough to do non-trivial surgery on Accelerate's sharing recovery algorithm.

  5. JktuJQ commented on Nov 4, 2026

    @JktuJQ
    Author

    I’d be interested in trying to work on this issue!

    After a preliminary look at Sharing.hs, separating bound-variable identity from observable sharing seems worth exploring.
    Atag and Tag already receive special treatment during sharing recovery: they become AvarSharing/VarSharing references regardless of their occurrence count.
    Could these references use explicit binding identities, while ordinary expressions continue to use StableNames?

    This indeed wouldn’t solve consequences that were already mentioned: ordinary expressions could still incur duplicated work and larger graphs, and missed cycles could cause graph conversion to diverge.
    I think these might be acceptable limitations: divergence for a cyclic definition doesn’t seem unreasonable (although it would happen during graph conversion rather than execution), missed sharing in acyclic expressions would become a case of "bad optimization" (though the overhead might be substantial).
    Would that be a reasonable trade-off, assuming we can preserve correct variable binding and scoping?

    If this sounds like a sensible direction, I’d appreciate any pointers on where to start or what pitfalls to watch out for.

  6. luite commented on Nov 4, 2026

    @luite
    I read this as "finalizers may run multiple times". Are you sure? If so, that sounds to me like a rather severe undocumented downside of finalizers that is likely to break almost all uses of them: freeing of external resources through ForeignPtr is kind of the prime use-case of them -- it's in the name!
    

    Not everything can be duplicated, so it depends on what exactly the weak reference is keyed on. If you use a MutVar# (IORef primitive) or other object with identity as key it's safe. They're never duplicated by the GC. I think that's what ForeignPtr does.

    But various packages use addFinalizer directly on a data constructor, which potentially goes wrong. But not often enough for them to have noticed it!

    I think the Weak# itself won't be duplicated, so the risk is use-after-free, not double-free (i.e. first copy of the data goes out of scope, finalizer runs because the weak sees the first as key, second copy still uses the data). But I haven't checked

  7. ivogabe commented on Nov 6, 2026

    @ivogabe
    Contributor

    Thanks for reporting this, @JktuJQ, and thanks @luite for your investigation & background info! The code of sharing recovery also already had a comment that the stable names do not provide all the guarantees we need:

    -- NB: Strictly speaking, this function is not deterministic, as it uses stable pointers to
    --     determine the sharing of subterms.  The stable pointer API does not guarantee its
    --     completeness; i.e., it may miss some equalities, which implies that we may fail to discover
    --     some sharing.  However, sharing does not affect the denotational meaning of an array
    --     computation; hence, we do not compromise denotational correctness.
    --
    --     There is one caveat: We currently rely on the 'Atag' and 'Tag' leaves representing free
    --     variables to be shared if any of them is used more than once.  If one is duplicated, the
    --     environment for de Bruijn conversion will have a duplicate entry, and hence, be of the wrong
    --     size, which is fatal. (The 'buildInitialEnv*' functions will already bail out.)

    (From Data.Array.Accelerate.Trafo.Sharing, function recoverSharingAcc)

    It seems like it was always fine on typical Accelerate programs, but still this is a correctness problem. Hence it is really nice that we have a consistent reproducer for this issue. If I understand correctly, you generate a huge Accelerate program, and this program often fails (and consistently fails if you compile this program 1000 times, with a parallel GC).

    This problem seems to be a fundamental limitation of stable names. A possible solution could be to assign our own names, instead of relying on GHC's stable names to identify equal objects. I think we could implement this by adding a 'name' (unique integer) to each SmartAcc object:

    type Id = Int
    newtype SmartAcc a = SmartAcc (PreSmartAcc SmartAcc SmartExp a) Id

    To construct these unique integers, we need to resort to some unsafe tricks, by incrementing an IORef. This breaks referential transparency, but we can keep this internal: Accelerate's semantics are still referential transparent, but performance will be different depending on whether something is shared or not (but that is in general the case, when sharing large computations).

    This could potentially be implemented internally via a smart constructor for SmartAcc:

    {-# NOINLINE freshId #-}
    freshId :: IORef Int
    freshId = unsafePerformIO $ newIORef 0
    
    smartAcc :: PreSmartAcc SmartAcc SmartExp a -> SmartAcc a
    smartAcc acc = unsafePerformIO $ do
      i <- atomicModifyIORef (\x -> (x + 1, x)) freshId
      return $ SmartAcc acc i

    All code I showed so far needs to be changed in Data.Array.Accelerate.Smart. Afterwards, sharing recovery in Data.Array.Accelerate.Trafo.Sharing needs to be updated to use our own indices instead of the stable names.

    Based on your discussion on finalizers, an alternative solution might be to store an object in SmartAcc that cannot be duplicated by the garbage collector, for instance:

    newtype SmartAcc a = SmartAcc (PreSmartAcc SmartAcc SmartExp a) (IORef ())

    In this case, the last field of SmartAcc is never used for its value. The smart constructor for SmartAcc still needs to perform IO from a pure function, but we don't need a global counter. Sharing recovery needs to be updated to take the stable name of the IORef instead of the SmartAcc or PreSmartAcc object, since the latter can still be duplicated. My first thought is however, that if we are going to work around the limitations of stable names, we can probably best not rely on them any more and go with our own names via the unique integers.

    Let me know what you think of these options. @JktuJQ if you want to work on a fix for this option, that would be greatly appreciated! I tried to sketch the general direction in this post, but we can of course discuss this further to get you started :)

  8. added
    frontendsharing recovery, fusion, optimisation
    bugsomething isn't working! >_<
    on Nov 6, 2026
  9. JktuJQ commented on Nov 6, 2026

    @JktuJQ
    Author

    For the first option, Data.Unique seems to provide exactly what you’re suggesting. For the second option, IORef already has an Eq instance that compares reference identity, so equality checks wouldn’t require StableName, though efficient table lookup would still need a suitable key.

    I’m leaning towards Unique: it expresses the intent clearly and provides Eq, Ord, and hashUnique (if needed) for efficient lookup.

    I’ll try to start working on this in the next 3-4 days and put together a PR once I have an implementation ready for review.

  10. JktuJQ commented on Nov 6, 2026

    @JktuJQ
    Author

    Ah, and we’ll also need to take care that optimizer transformations don’t change how often newUnique runs inside unsafePerformIO (the same goes for IORef), but this is a known pattern, so existing implementations should provide useful guidance.

  11. ivogabe commented on Nov 6, 2026

    @ivogabe
    Contributor

    Yes good point, Unique is even better than Int here, so let's first try that direction! It would be great if you could create a PR for this; let me know if you need additional guidance.

    Also note that everything we discussed for Acc also applied to Exp (but you may have guessed that already).

  12. JktuJQ commented on Nov 9, 2026

    @JktuJQ
    Author

    I've opened PR #590 with a proposed fix using stored node identities instead of StableName during sharing recovery.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugsomething isn't working! >_<frontendsharing recovery, fusion, optimisationhelp wanted

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions