Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[RISC-V][x64] WiP: Passing empty struct fields #101796

Closed
wants to merge 70 commits into from

Conversation

tomeksowi
Copy link
Contributor

@tomeksowi tomeksowi commented May 2, 2024

This PR should be viewed as proof of concept, it will be upstreamed in smaller chunks so it can be reviewed with some confidence.

RISC-V and LoongArch

Small structs containing one or two fields, at least one of them floating-point, can be passed (or returned) according to hardware FP calling convention: each field occupying one register. The existing implementation worked only for the narrow case when the fields were naturally aligned. However, the ABIs on both platforms when enregistering fields disregard placement hints such as manual alignment, packing attributes, or padding with empty structs (when they are sized 1 byte like in C++ or .NET). This means additional information on field offsets and sizes needs to be passed wherever registers<->memory copying of such structs happens.

RISC-V only: Unlike LoongArch's, RISC-V's ABI does not bound the size of such structs to 16 bytes. This means, among other things, that we can no longer rule out struct's eligibility for passing according to hardware FP calling convention by simply checking size > 16, which is assumed in many places.

System V x86-64

The current implementation barred a struct containing empty struct fields from enregistration. This did not match the System V ABI which says "NO_CLASS This class is used as initializer in the algorithms. It will be used for padding and empty structures and unions". It also does not match the behavior of GCC & Clang on Linux.

Part of #84834, cc @dotnet/samsung

@dotnet-issue-labeler dotnet-issue-labeler bot added the area-CodeGen-coreclr CLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI label May 2, 2024
@dotnet-policy-service dotnet-policy-service bot added the community-contribution Indicates that the PR has been added by a community member label May 2, 2024
Copy link
Contributor

Tagging subscribers to this area: @JulieLeeMSFT, @jakobbotsch
See info in area-owners.md if you want to be subscribed.

@clamp03 clamp03 added the arch-riscv Related to the RISC-V architecture label May 3, 2024
The current implementation barred a struct containing empty struct fields from enregistration. This did not match the [System V ABI](https://refspecs.linuxbase.org/elf/x86_64-abi-0.99.pdf) which says "NO_CLASS This class is used as initializer in the algorithms. It will be used for padding and **empty structures** and unions". It also does not match the behavior of GCC & Clang on Linux.
Comment on lines 823 to 828
[StructLayout(LayoutKind.Explicit, Pack=1)]
struct ExplicitFloatLong
{
[FieldOffset(1)] float FieldF;
[FieldOffset(5)] long FieldL;

Copy link
Contributor Author

Choose a reason for hiding this comment

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

For the record, I don't think Pack should be necessary. From what I dug in the codebase, it's a known problem:

if (result < 8 && pMT->RequiresAlign8())
{
// If the structure contains 64-bit primitive fields and the platform requires 8-byte alignment for
// such fields then make sure we return at least 8-byte alignment. Note that it's technically possible
// to create unmanaged APIs that take unaligned structures containing such fields and this
// unconditional alignment bump would cause us to get the calling convention wrong on platforms such
// as ARM. If we see such cases in the future we'd need to add another control (such as an alignment
// property for the StructLayout attribute or a marshaling directive attribute for p/invoke arguments)
// that allows more precise control. For now we'll go with the likely scenario.
result = 8;
}

IMHO the fix would be something like:

  • In CalculateSizeAndFieldOffsets amend calculating alignmentRequirement to:
    GCD(min(alignmentRequirement, packingSize), placementInfo->m_offset)
  • Amend this condition to take explicit offset into consideration:
    // For types with layout we drop any 64-bit alignment requirement if the packing size was less than 8
    // bytes (this mimics what the native compiler does and ensures we match up calling conventions during
    // interop).
    // We don't do this for types that are marked as sequential but end up with auto-layout due to containing pointers,
    // as auto-layout ignores any Pack directives.
    if (HasLayout() && (HasExplicitFieldOffsetLayout() || IsManagedSequential()) && GetLayoutInfo()->GetPackingSize() < 8)
    {
    fFieldRequiresAlign8 = false;
    }

But since this doesn't have much to do with empty structs, I'll leave it for now to keep this PR focused.

…alling convention, in ArgIterator::GetNextOffset and RiscV64Classifier
…n buffer; always calculate GetRiscV64PassStructInRegisterFlags in ComputeReturnFlags\(\)
…. We still need to look at GetRiscV64PassStructInRegisterFlags to rule out passing in registers according to hw FP call conv
…o don't calculate GetRiscV64PassStructInRegisterFlags if struct fits in 16 bytes because we don't care whether it's passed in registers according to integer or hardware floating-point calling convention
Rework CallDescrWorkerInternal to do simple register saving into CallDescrWorker::returnValue rather than try to reconstruct the struct there. Reconstruction respecting the actual field layout is handled then by CopyReturnedFpStructFromRegisters.

This fixes most reflection call tests in JIT/Directed/StructABI/StructABI.
…an argument is passed by ref by looking at m_hasArgLocDescForStructInRegs in ArgIteratorTemplate::IsArgPassedByRef()
…ntion to final destination in MethodDescCallSite::CallTargetWorker
Comment on lines 3173 to 3176
#elif defined(TARGET_RISCV64) || defined(TARGET_LOONGARCH64)
// On RISC-V/LoongArch struct { struct{} e1,e2,e3; byte b; float f; } is passed in 2 registers so the
// load/store instruction for 'b' needs to be exact in size or it will overlap 'f'.
return seg.GetRegisterType();
Copy link
Member

Choose a reason for hiding this comment

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

Can you share some information about the case this fixes? Given that promoted struct fields are normalize-on-load, this special case should not be necessary unless there is a bug elsewhere in the RISCV64/LA64 backends.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It was for cases like this:

public struct EmptyFloatEmpty5Byte
{
public Empty e;
public float FieldF;
public Empty e0, e1, e2, e3, e4;
public sbyte FieldB;
public static EmptyFloatEmpty5Byte Get()
{
return new EmptyFloatEmpty5Byte { FieldF = 3.14159f, FieldB = -123 };
}
public bool Equals(EmptyFloatEmpty5Byte other)
{
return FieldF.Equals(other.FieldF) && FieldB == other.FieldB;
}
}

Homing the argument in the EmptyFloatEmpty5Byte:Equals prolog trashed the this pointer:

IN0016: 000000      addi           sp, sp, -48
IN0017: 000004      sd             fp, 32(sp)
IN0018: 000008      sd             ra, 40(sp)
IN0019: 00000C      addi           fp, sp, 32
IN001a: 000010      sd             a0, -8(fp)  // store 'this'
IN001b: 000014      fsw            f10, -20(fp)
IN001c: 000018      sw             a1, -11(fp)  // stomp on 'this'

I saw native compilers home arguments with appropriately-sized stores at original struct offsets so I fixed it the same way.

Full JitDump of EmptyFloatEmpty5Byte:Equals if interested

Copy link
Member

Choose a reason for hiding this comment

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

Got it, I think the fix makes sense. The comment doesn't seem fully accurate then; the real problem seems to be that since the enregistered layout does not match the memory layout of the struct, rounding up the size would potentially extend outside the stack slot.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Right, I'll reword the comment.

Oh, and please view this PR as a proof of concept. I need to upstream it in smaller chunks so it can be reviewed with some confidence.

Comment on lines +3162 to +3175
static FpStructInRegistersInfo GetRiscV64PassFpStructInRegistersInfoImpl(TypeHandle th)
{
FpStructInRegistersInfo info = {};
int nFields = 0;
if (!FlattenFields(th, 0, info, nFields DEBUG_ARG(0)))
return FpStructInRegistersInfo{};

using namespace FpStruct;
if ((info.flags & (FloatInt | IntFloat)) == 0)
{
LOG((LF_JIT, LL_EVERYTHING, "FpStructInRegistersInfo: struct %s (%u bytes) has no floating fields\n",
(!th.IsTypeDesc() ? th.AsMethodTable() : th.AsNativeValueType())->GetDebugClassName(), th.GetSize()));
return FpStructInRegistersInfo{};
}
Copy link
Member

Choose a reason for hiding this comment

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

As I alluded to in some other PRs we have the notion of significant padding where it can essentially be considered that all the padding of a struct is covered by fields. If you look at the getTypeLayout implementation you can see how it is computed. The JIT takes care to preserve values in bytes not covered by fields in those cases, and the ABI classification will need to do the same.

For example, a structure declaration like

private unsafe struct S
{
    public fixed byte Foo[15];
    public float Bar;
}

results in underlying metadata that looks like

private unsafe struct S
{
  public FooStruct Foo;
  public float Bar;
}

[StructLayout(LayoutKind.Sequential, Size = 15)]
private struct FooStruct
{
  public byte FixedElementField;
}

I'm curious how this code ends up classifying a struct like this one for passing.

It seems like RISC-V/LA64 are going to end up with some potentially surprising user behavior here because of these ABI differences when padding is involved, and because it is somewhat ambiguous to the VM/JIT what is (ignorable) padding (or at least not totally obvious to the user).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Here's a log from a similar case in JIT/Directed/StructABI/StructABI/StructABI.cs:

TID 3f2277: FpStructInRegistersInfo: flattening InlineArray1 (managed, 1 fields)
TID 3f2277: FpStructInRegistersInfo:     flattening <Array>e__FixedBuffer (managed, 1 fields)
TID 3f2277: FpStructInRegistersInfo:      * found field FixedElementField [0..1), type: Byte
TID 3f2277: FpStructInRegistersInfo:          * array has too many elements: 16

So it stops the field flattening because there's too many fields (max 2 fields to get passed according to FP calling convention) and returns an empty FpStructInRegistersInfo which means pass according to integer calling convention where there is no notion of "fields", structs are a lump of bits laid out in registers as in memory. But it this case struct S is bigger than 16 bytes so it's passed by implicit ref.

Copy link
Member

Choose a reason for hiding this comment

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

I see now the handling for HasImpliedRepeatedField; that might have to be generalized somewhat. What about the following example?

[StructLayout(LayoutKind.Explicit, Size = 20)]
struct S
{
  [FieldOffset(0)]
  public byte FirstByteOfArray;
  [FieldOffset(16)]
  public float FloatField;
}

Maybe SysV classification needs some generalization too (I can see it uses HasImpliedRepeatedFields too). Or perhaps it is the significant padding computation in getTypeLayout that is overly conservative.

Copy link
Contributor Author

@tomeksowi tomeksowi Jun 25, 2024

Choose a reason for hiding this comment

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

I think as expected:

TID 3f849f: FpStructInRegistersInfo: flattening S (managed, 2 fields)
TID 3f849f: FpStructInRegistersInfo:  * found field FirstByteOfArray [0..1), type: Byte
TID 3f849f: FpStructInRegistersInfo:  * found field FloatField [12..16), type: Single
TID 3f849f: FpStructInRegistersInfo: struct S (16 bytes) can be passed with floating-point calling convention, flags=0x88; IntFloat, sizes={1, 4}, offsets={0, 12}, IntFieldKindMask=Integer

Note: I downsized to 16 bytes as I'm on #103945 branch where the condition has not been relaxed for RISC-V. But I think it answers your question (padding via explicit layout).

There are problems with handling fixed buffers as the comment in HasImpliedRepeatedField implies. But as far as the classification for RISC-V goes I think it's ok.

Copy link
Member

Choose a reason for hiding this comment

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

getTypeLayout considers the above to be equivalent to a struct struct S {public fixed byte Array[16]; public float FloatField; }. So in that view I don't think the RISC-V classification is ok; IIUC it will silently drop parts of the struct that may contain user data when it gets passed as an argument.

The ABI classification here should match what getTypeLayout decides, one way or the other. I'm not sure if it is possible for us to change what it considers significant padding, since that has been in the JIT for a long time now.

Copy link
Member

Choose a reason for hiding this comment

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

How would the user write data to e.g. FirstByteOfArray[5] with standard language features?

Using unsafe code. I am not sure of the historical details of how things evolved this way, but I would guess C++/CLI is a large part of it.

Native compilers in general don't consider padding to be preserved while passing, definitely not the RISC-V ABI. That's the whole point of passing a struct according to FP calling convention, as two fields each in one register.

The difference in .NET is simply what we consider to be the discardable padding. We also have discardable padding, like the last 4 bytes of Span<T>. But ExplicitLayout/explicit size automatically promote the padding of the struct to be considered as "must be preserved".
I believe most of your example test cases have discardable padding since they do not have ExplicitLayout/explicit size, so there I think the ABI classification done here makes sense. But for the example given above things are different, where the ABI classification should essentially be done as if the padding was replaced by explicit char arrays of the right size.

BTW what would be the .NET equivalent for this C/C++ struct?

struct {
    char i;
    alignas(16) float f;
};

I am not sure we have an equivalent. I think there are both .NET structs (like the ones with ExplicitLayout/Size) that are not representable in C/C++; and C/C++ structs (like your example) that are not representable in .NET metadata. @AaronRobinsonMSFT, @jkoritzinsky or @jkotas should know more about the interop story here...

That's news to me:/ I don't see getTypeLayout used for parameter classification neither on System V nor on RISC-V/LoongArch.

I mention getTypeLayout because it has the current source of truth of when we consider padding in structs to be significant/required to be preserved. I do not know if the rules in there could be relaxed; just that changing the rules would be breaking, and that users are potentially relying on the values in padding of such structures to be preserved. So in that sense it becomes a problem if the ABI does not match with these rules.

I believe all of our existing ABIs come with rules that will preserve this padding, and thus we have not needed to pay any special attention to it before. I could be wrong about this on SysV, in which case I would consider it a bug.

Ultimately I think getTypeLayout and the ABI classification need to agree on what padding in a struct is significant and needs to be preserved, but I do not know whether it would be possible to relax the rules in getTypeLayout or not.

Copy link
Member

Choose a reason for hiding this comment

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

Are there any rules defined for what padding is considered significant or just whatever is in the code for getTypeLayout? Becuase treating padding as an array does influence the classification for passing so the user would need to be aware of it. The remarks in StructLayout documentation say it's for controlling the layout to pass a type to unmanaged code, i.e. match the layout of the unmanaged type, and it doesn't mention any of it.

I don't think we explicitly document this part of the rules anywhere (and as you can see in #71711, the semantics were not clear even to ourselves for a long time). I wouldn't be surprised if CUSTOMLAYOUT came to exist quite organically when some issue was noticed where the JIT discarded padding that was expected to be preserved.

Copy link
Contributor Author

@tomeksowi tomeksowi Jun 25, 2024

Choose a reason for hiding this comment

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

I believe most of your example test cases have discardable padding since they do not have ExplicitLayout/explicit size, so there I think the ABI classification done here makes sense. But for the example given above things are different, where the ABI classification should essentially be done as if the padding was replaced by explicit char arrays of the right size.

Right, the bulk of this PR is about empty struct fields, I'll leave out the ExplicitLayout test cases until we hammer out what to do with it, then I'll address it in a dedicated PR.

At any rate, the condition for which padding is preservable needs to be communicated loud and clear because it may change how the argument is passed. I think the best course of action would be to specify something like "When <condition for significant padding>, the field with its padding until the next field is treated as a fixed array of type same as the field" and then let the platform ABI decide how to pass a struct with that additional array so we're not inventing a custom ABI.

Copy link
Member

Choose a reason for hiding this comment

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

I think there are both .NET structs (like the ones with ExplicitLayout/Size) that are not representable in C/C++; and C/C++ structs (like your example) that are not representable in .NET metadata.

Yes, that sounds about right.

Copy link
Member

Choose a reason for hiding this comment

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

I think there are both .NET structs (like the ones with ExplicitLayout/Size) that are not representable in C/C++; and C/C++ structs (like your example) that are not representable in .NET metadata.

Agree.

Copy link
Contributor

Draft Pull Request was automatically closed for 30 days of inactivity. Please let us know if you'd like to reopen it.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
arch-riscv Related to the RISC-V architecture area-CodeGen-coreclr CLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI community-contribution Indicates that the PR has been added by a community member
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants