MDX Essentials: Enhancing CROSSJOIN() with Calculated Members

Monday Apr 4th 2005 by William Pearson
Share:

Join MSAS Architect Bill Pearson in an extension to the previous examination of CROSSJOIN() enhancement. Discover, through a multi-step practice exercise, why NONEMPTYCROSSJOIN() proves ineffective when calculated members enter the picture, and how we can enhance performance through alternative avenues.

About the Series ...

This article is a member of the series, MDX Essentials. The series is designed to provide hands-on application of the fundamentals of the Multidimensional Expressions (MDX) language, with each tutorial progressively adding features designed to meet specific real-world needs.

For more information about the series in general, as well as the software and systems requirements for getting the most out of the lessons included, please see my first article, MDX at First Glance: Introduction to MDX Essentials.

Note: Service Pack 3 updates are assumed for MSSQL Server 2000, MSSQL Server 2000 Analysis Services, and the related Books Online and Samples.

Overview

In an earlier article, The CROSSJOIN() Function: Breaking Bottlenecks, we examined the use of CROSSJOIN(), and factors that can render this otherwise powerful function suboptimal within our queries. We discussed a business need as defined by a hypothetical group of information consumers, in which we were asked to tune an MDX query for more optimal performance. Our focus centered upon enhancing query performance when using CROSSJOIN() in medium- to large-sized data sets. After discussing how CROSSJOIN() works in general, and pointing out the way in which its operations can result in crippling performance overhead, we exposed approaches to mitigating that overhead within practice exercises designed to reinforce the concepts.

We learned that using NONEMPTYCROSSJOIN() is, by far, the most effective avenue to minimizing the bottlenecks that plague standard CROSSJOINS() within challenging cube scenarios. We examined two approaches to using NONEMPTYCROSSJOIN() in achieving our ends, finding refinements in the second approach, where we employed the optional set count parameter in the function, to provide more efficiency than the first (which, even in its "vanilla" context, had demonstrated its power to enhance performance dramatically). We noted, however, a prevailing concern amid all this success: NONEMPTYCROSSJOIN() filters out calculated members, and so it is not useful in a scenario where calculated members are to be returned.

In this article, we will examine the enhancement of queries using CROSSJOIN() where calculated members are, indeed, to be returned. We will begin with a simple, but intensive, CROSSJOIN() scenario, reviewing how CROSSJOIN() performance can become an issue where larger sized data sets are involved. We will then undertake a multiple-step practice example, which will initially help us to gain an understanding of the issues encountered with calculated members. We will attempt the approach to minimizing performance overhead that we used in The CROSSJOIN() Function: Breaking Bottlenecks, where we met with a simpler scenario that did not involve calculated members. We will then provide an approach that provides palpable relief of the performance issues, while returning a calculated member that we require to achieve our reporting and analysis objectives.

To accomplish our examination of CROSSJOIN() enhancement when calculated members are a factor, we will undertake the following steps in this article:

  • Review CROSSJOIN() performance considerations that we introduced earlier;
  • Create a copy of the Warehouse sample cube for use in our practice exercise;
  • Add a calculated member to a dimension in the clone cube for consideration within our article;
  • Prepare the cube further by processing;
  • Examine an instance of suboptimal query performance that we determine to be due to the resource-intensive use of CROSSJOIN(), highlighting factors that cause performance degradation;
  • Demonstrate issues inherent in the attempt to enhance a suboptimal CROSSJOIN() scenario by substituting NONEMPTYCROSSJOIN(), when calculated members need to be retrieved;
  • Provide an approach to enhancement of the CROSSJOIN() scenario with concomitant return of calculated members, using the GENERATE() function;
  • Explain the results we obtain from the steps we take to accomplish the solution.

Introduction

In The CROSSJOIN() Function: Breaking Bottlenecks, we introduced our efforts to enhance CROSSJOIN() by discussing how the function can contribute to a degradation in processing for queries that rely upon it in scenarios where we encounter medium- to large-sized data sets. We acknowledged the utility of the CROSSJOIN() function in scenarios where we wish to generate a cross-product of members in two different sets, noting that the capability that it gives us to specify "all possible combinations" is convenient - indeed, the most straightforward way to perform such a combination of two sets.

As we noted next, however, the indiscriminate use of the CROSSJOIN() function, like many other MDX functions, can slow reporting and analysis dramatically. The consequential degradation of processing, we added, is often due to a failure to understand how the function performs set combinations, and how its action can lead to huge results datasets when applied to large cubes.

NOTE: For detailed introductory information on the CROSSJOIN() function, see my Database Journal article MDX Essentials: Basic Set Functions: The CrossJoin() Function.

In The CROSSJOIN() Function: Breaking Bottlenecks, we discussed that, in combining two sets, CROSSJOIN() combines every member of the first set (all from a single dimension) with every member of the second, creating a "Cartesian" effect as a result. We further noted that combining two sets, for example, with the following query illustrates a scenario, (on the scale of the small Warehouse sample that installs with MSAS), where we experience an appreciation for the consequences when the number of members in set 1, times the number of members in set 2, times the member population in set 3, results in many combinations.


SELECT 
    {[Measures].[Warehouse Profit]} ON COLUMNS,
   {CROSSJOIN([Warehouse].[Warehouse Name].Members,
        CROSSJOIN([Store].[Store Name].Members,
           [Product].[Product Name].Members))} ON ROWS
FROM  
   [WAREHOUSE]

We stated that this query would generate 898,560 combinations (24 individual Warehouses times 24 Stores times 1,560 distinct Products). The number of combinations in a cube of the vastly larger sizes that occur in today's business environment can obviously be crippling to performance. This is aggravated by the fact that the sparsity that is prevalent in large cubes naturally leads to CROSSJOIN() results with an even higher sparsity factor. (The results dataset produced by the above query yields only a tiny fraction of combinations with non-empty measures.)

We learned that the process of generating all possible combinations, empty or not, lies behind the performance drag, and becomes even more pronounced when we attempt to perform query operations that must wait for the combinations to be assembled, and then be applied to the resulting dataset. The time consumed in assembling a large number of empty combinations in these scenarios is largely wasted when a large percentage are "tossed" in a subsequent step in the march toward the ultimate results. We will see an example of this within this article, where we will begin with a query that is suffering from the kind of degradation we are talking about.

For purposes of our practice procedure, we will assume that we have been asked by management of a hypothetical client, once again, to investigate degradation in performance of a query. The query was originally constructed at the request of a group of information consumers in the Corporate Planning department, shortly after the implementation of MSAS at the FoodMart organization. The creator of the query, who initially wrote the MDX in a way that seemed intuitive, intended to optimize it later. When his position evaporated with the movement of many IT functions to an offshore organization, the query was left in its original, suboptimal state.

Attempts to communicate with the offshore support team were abandoned when it was learned that the building housing the group was destroyed in a recent regional disaster. While most of the documentation, code, and other collateral for in-process enterprise projects, together with corporate financial information and customer information files, has been reported missing, we are able to locate the query details among several files left by the original author on a development server.

We discuss the requirement with the information consumers, and gain insight into a potentially important factor that might affect our optimization strategy. According to the consumers, the production version of the Warehouse cube under consideration contained an additional calculated member, added to meet a specific reporting requirement. We develop a plan to examine the query under consideration, before offering options for improving its performance.

As is often the case, we decide to work with a copy of the development version of the Warehouse cube, to allow the original to remain isolated. It is to this copy of the cube that we will make the modification required to bring it into alignment with the cube under consideration, adding the calculated member to the clone in preparation for our optimization exercises.

Practice

Preparation

Create a Clone Cube

Let's get started by creating a clone of the Warehouse sample cube, which, along with the FoodMart database that contains it, accompanies an MSAS installation. This will allow us to keep the original sample cube intact for other uses, while adding a calculated member we need to simulate the production cube upon which our practice example focuses.

1.  Open Analysis Manager, beginning at the Start menu.

2.  Expand the Analysis Servers folder by clicking the "+" sign to its immediate left.

Our server(s) appear.

3.  Expand the desired server.

Our database(s) appear, in much the same manner as depicted in Illustration 1.


Illustration 1: A Sample Set of Databases Displayed within Analysis Manager

4.  Expand the FoodMart 2000 database.

5.  Expand the Cubes folder.

The sample cubes appear, as shown in Illustration 2.


Illustration 2: The Sample Cubes in the FoodMart 2000 Database

NOTE: Your local databases / cube tree will differ, depending upon the activities you have performed since the installation of MSAS (and the simultaneous creation of the original set of sample cubes). Should you want or need to restore the cubes to their original state, simply restore the database under consideration. For instructions, see the MSSQL Server 2000 Books Online.

6.  Right-click on the Warehouse sample cube.

Again, we are making a copy of the Warehouse cube to isolate it. Our lesson will involve the execution of demanding queries upon the cube we use within the practice example. Our intention is to work with an isolated cube, to which we will be making modifications, and to leave the original fully available to other users.

7.  Select Copy from the context menu that appears.

8.  Right-click on the Cubes folder.

9.  Select Paste from the context menu that appears.

The Duplicate Name dialog appears.

As noted in previous articles, we cannot have two cubes of the same name in a given MSAS database.

10.  Type the following into the Name box of the Duplicate Name dialog:

MDX30 Optimize CrossJoin

The Duplicate Name dialog appears, with our modification, as depicted in Illustration 3.


Illustration 3: The Duplicate Name Dialog, with New Name

TIP: As I have mentioned elsewhere in this and other series, the foregoing is also an excellent way of renaming a cube, (a "rename" capability is not available here, as it is in many Windows applications). Simply create a duplicate, give it the name to which you wish to rename the old cube, and then delete the old cube, as appropriate. This also works for MSAS databases, dimensions and other objects.

11.  Click OK to apply the name change, and create the cube.

The new cube, MDX30 OPTIMIZE CROSSJOIN , appears in the cube tree, among those already in place. We now have a copy of the Warehouse cube, within which we can perform the steps of our practice exercise. Let's process the new cube to "register" it with Analysis Services, and to reach the "processed" state required for querying.

Process the Clone Cube

1.  Right-click the new MDX30 OPTIMIZE CROSSJOIN cube.

2.  Select Process... from the context menu that appears, as shown in Illustration 4.

Click for larger image

Illustration 4: Select Process... from the Context Menu

The Process a Cube dialog appears, as depicted in Illustration 5, with the processing method defaulted to Full Process (as this is the first time the cube has been processed).

Click for larger image

Illustration 5: Full Process Selected in the Process a Cube Dialog

3.  Click OK to begin processing.

Processing begins. The Process viewer displays various logged events, then presents a green Processing completed successfully message, as shown in Illustration 6.


Illustration 6: Indication of Successful Processing Appears (Compact View)

4.  Click Close to dismiss the viewer.

We are now ready to further prepare the MDX30 OPTIMIZE CROSSJOIN cube, by adding a calculated member that we know to have existed in the now-missing production cube. This calculated member will also form the focus of our practice example, as it will provide the driver for our approach to optimizing the query we discussed earlier with the information consumers.



Add a Calculated Member



In discussions with the information consumers, we have learned that a calculated member, upon which they rely for specific reporting needs, existed in the production cube before its disappearance. The calculated member was called Non-Domestic, and was simply a grouping mechanism to label the non-U.S. Store operations of the FoodMart organization (consisting of activities in Canada and Mexico) for occasional reporting requirements. The effect was thus to allow, side-by-side with the USA, Canada, and Mexico geographical groupings of Stores, the ability to easily see an aggregation of Non-US store values in a simple line item. The consumers use the item for specific and limited purposes only, and realize that it could present a "double count" in scenarios where, for example, a report might be generated that simply listed all groupings, including the "external grouping" provided by Non-Domestic.

Let's create this straightforward calculated member by taking the following steps.



1.  Right-click the new MDX30 OPTIMIZE CROSSJOIN cube.



2.  Select Edit... from the context menu that appears, as depicted in Illustration 7.




Illustration 7: Select Edit... from the Context Menu

The Cube Editor opens.

3.  Right-click the Calculated Members folder in the Tree Pane.

4.  Select New Calculated Member... from the context menu that appears, as shown in Illustration 8.


Illustration 8: Select New Calculated Member... from the Context Menu

The Calculated Member Builder opens.

5.  In the Parent dimension selector, select Store.

6.  Click the Change ... button to the immediate right of the Parent member box.

7.  The Select the parent member dialog opens.

8.  Click All Stores in the dialog to select it, as depicted in Illustration 9.


Illustration 9: Select All Stores as the Parent Member

9.  Click OK to accept the selection and close the dialog.

10.  Type Non-Domestic into the Member name box of the Calculated Member Builder.

11.  Click the "+" sign to the immediate left of Store in the Data pane of the Calculated Member Builder.

12.  Click the "+" sign to the immediate left of Store Country that appears.

13.  Click Canada to select it.

14.  Click the Insert button, once Canada is highlighted, to insert the appropriate syntax into the Value expression box just above.

15.  Using the keypad under the Insert button, click the "+" sign.

The "+" sign appears in the Value expression box, to the immediate right of the Canada syntax we added in the previous steps.

16.  Click Mexico, just under Canada, in the Data pane of the Calculated Member Builder, to select it.

17.  Click the Insert button to insert the appropriate syntax for Mexico Stores into the Value expression box just above.

The syntax appears in the Value expression box, to the immediate right of the syntax we added in the previous steps. The Calculated Member Builder, with our additions, appears as shown in Illustration 10.


Illustration 10: The Calculated Member Builder (Compressed View) with Our Additions

18.  Click the Check button to perform a quick syntax check.

A message box appears, indicating that syntax is valid, as depicted in Illustration 11.


Illustration 11: Message Box Indicates that Syntax is Valid

19.  Click OK to close the message box.

20.  Click OK to accept input and close the Calculated Member Builder.

The Calculated Member Builder closes, and the Non-Domestic calculated member appears in the Calculated Members folder in the Tree Pane.

21.  Open the Properties pane underneath the Tree pane, if required.

22.  Click the Basic tab of the Properties pane, as necessary.

The Non-Domestic calculated member, with associated Properties pane, Basic tab, appears in the Calculated Members folder in the Tree Pane, as shown in Illustration 12.


Illustration 12: The Non-Domestic Calculated Member (with Basic Properties)

23.  Click the Data tab to the right of the Properties pane, to access the Cube Editor's Data view.

24.  Click the downward arrow for the Store member in the Data Slicing pane, atop the Data view (it displays All Stores, by default).

25.  Expand All Stores in the tree that appears, clicking on the "+" sign to its immediate left.

The Stores member expands to the next dimensional level, where, along with the FoodMart Store Countries, we see our new calculated member, Non-Domestic, as depicted in Illustration 13.


Illustration 13: The Non-Domestic Calculated Member Appears in the Store Country Level

We now have a clone cube that resembles the lost production cube in all material respects. Our next steps will be to analyze and enhance the query that has been presented to us by the information consumers. To do this, we will return to the MDX Sample Application.

26.  Select File -> Exit to close the Cube Editor, saving the cube when prompted.

27.  Exit Analysis Manager when ready.

Procedure

Having created a clone cube, complete with the calculated member that appeared in the now-lost production cube, we can pursue our objective of CROSSJOIN() optimization. Let's initialize the MDX Sample Application, as a platform from which to perform our practice exercises, taking the following steps:

1.  Start the MDX Sample Application.

We are initially greeted by the Connect dialog, shown in Illustration 14.

Click for larger image

Illustration 14: The Connect Dialog for the MDX Sample Application

The illustration above depicts the name of my server, MOTHER1, and properly indicates that we will be connecting via the MSOLAP provider (the default).

2.  Click OK.

The MDX Sample Application window appears.

3.  Ensure that FoodMart 2000 is selected as the database name in the DB box of the toolbar.

4.  Select the new MDX30 OPTIMIZE CROSSJOIN cube in the Cube drop-down list box.

5.  Click File -> New to open a blank Query pane.

The MDX Sample Application window should resemble that depicted in Illustration 15, complete with the information from the MDX30 OPTIMIZE CROSSJOIN cube displaying in the Metadata tree (left section of the Metadata pane).


Illustration 15: The MDX Sample Application Window (Compressed View)

We will begin creating our query with a focus on returning results efficiently. As we mentioned earlier, we are able to obtain the original query from the development server abandoned by the developer upon his lay off. We have requested the specific requirements for the query from the information consumers, simply to confirm that the query is conceptually sound (it makes little sense to attempt to enhance a query that is not designed to return the correct data in the first place, no matter how efficient its performance).

The consumers explain that they have requested to see a simple summary of 1998 Warehouse Sales, by Product Name, and by Store Country, for a specific Warehouse, Bellmont Distributing in Vancouver, Canada. The query not only meets an immediate need, but will act prospectively as a template for identical queries that will be directed against other Warehouse locations (both singly and in groups).

NOTE: Parameterization will be managed within MSSQL Server Reporting Services, but that is beyond the scope of this article. For a discussion of how this might be handled in general, see my Database Journal article Mastering OLAP Reporting: Cascading Prompts. For an approach whereby the picklists supporting parameterization might be cube-based, see MDX in Analysis Services: Create a Cube-Based Hierarchical Picklist.

The query appears as follows:


SELECT      
    {[Measures].[Warehouse Sales]} ON COLUMNS,
    {CROSSJOIN({[Warehouse].[All Warehouses].[Canada].[BC].[Vancouver].
        [Bellmont Distributing]},
CROSSJOIN([Store].[Store Country].AllMembers, 
       [Product].[Product Name].Members))} ON ROWS
FROM  
   [MDX30 Optimize CrossJoin]
WHERE
    ([Time].[1998])

The consumers with whom we are interacting tell us that the query does, indeed, give them the results they want, in the appropriate general layout (although they would prefer that it did not display line items for Products with no activity: the high volume of "blanks" makes the data output far too lengthy). We determine that we will create an identical query in the MDX Sample Application, upon which we will apply enhancements to tune its performance. We will save each step as a separate query to allow us to "fall back," if necessary, to a previous step, as we incrementally modify the query.

1.  Create the following new query (identical, except for comment line, to the original):


--MDX30-01:  Original Query (Suboptimal)
SELECT      
    {[Measures].[Warehouse Sales]} ON COLUMNS,
    {CROSSJOIN({[Warehouse].[All Warehouses].[Canada].[BC].[Vancouver].
        [Bellmont Distributing]},
CROSSJOIN([Store].[Store Country].AllMembers, 
       [Product].[Product Name].Members))} ON ROWS
FROM  
   [MDX30 Optimize CrossJoin]
WHERE
    ([Time].[1998])

2.  Execute the query using the Run Query button.

After running for up to a minute, perhaps longer on resource-challenged machines, (the query is processing intensive), the topmost section of the results dataset appears as shown in Illustration 16.


Illustration 16: The Results Dataset (Top Section Only) - Original Approach

Based upon what we know about the CROSSJOIN() function, we can readily see that the query above can be optimized. First, we note that the query as originally written creates, for the Bellmont Distributing Warehouse, every Store Country (four, including the new Non-Domestic calculated member) and Product Name (1,560 exist) combination. This is only 6,240 combinations, but we certainly must acknowledge that the query is time consuming.

3.  Scroll down in the viewer to ascertain that the Non-Domestic calculated member appears, as depicted in Illustration 17.

Click for larger image

Illustration 17: The Results Dataset (Top Section Only) - Original Approach

As we noted in our previous article, there are many "empties" that we might attempt to eliminate as a first step in enhancing query performance. The complication within our current enhancement effort lies in the expressed business requirement to return the calculated member. While we found the NONEMPTYCROSSJOIN() a helpful ally, in The CROSSJOIN() Function: Breaking Bottlenecks, in a scenario that was devoid of calculated member considerations, we will see that it tends to "scalp" the returned dataset of calculated members when they are present.

4.  Save the query as MDX30-01.

5.  Create the following new query, where we substitute the NONEMPTYCROSSJOIN() approach:


--MDX30-02: NONEMPTYCROSSJOIN() fails to recognize Calculated Members
SELECT 
    {[Measures].[Warehouse Sales]} ON COLUMNS,
    {NONEMPTYCROSSJOIN({[Warehouse].[All Warehouses].[Canada].[BC].[Vancouver].
        [Bellmont Distributing]}, [Store].[Store Country].AllMembers, 
             [Product].[Product Name].Members)} ON ROWS
FROM  
   [MDX30 Optimize CrossJoin]
WHERE
    ([Time].[1998])

6.  Execute the query using the Run Query button.

The query executes far more rapidly, and the empties are eliminated. We note, however, if we attempt to scroll to the Non-Domestic calculated member once again, that it no longer appears - even though we retain .AllMembers in our Store Country specification. This, as we have already intimated, is because the NONEMPTYCROSSJOIN() function strips the calculated member out of the returned results dataset. This eliminates the powerful NONEMPTYCROSSJOIN() option in the current scenario, not to mention the option to tune NONEMPTYCROSSJOIN() even further, with the addition of the set-count parameter that we witnessed in the final refinement of our solution in The CROSSJOIN() Function: Breaking Bottleneck.

Fortunately, as we have seen myriad times in the past, MDX offers alternative approaches to meeting our needs. To accomplish our ends, enhancement of the query with retrieval of the calculated member, we can employ a combination of the CROSSJOIN() and FILTER() functions, together with NOT ISEMPTY(), as we shall see in the next steps.

7.  Save the query as MDX30-02.

Fortunately, as we have seen myriad times in the past, MDX offers alternative approaches to meeting our needs. To accomplish our ends - enhancement of the query with retrieval of the calculated member - we can employ a combination of the CROSSJOIN() and FILTER() functions, together with NOT ISEMPTY(), as we shall see in the next steps.

8.  Create the following new query, where we substitute the new approach NONEMPTYCROSSJOIN() approach:


--MDX30-03: Using GENERATE() to enhance CROSSJOIN() performance when
--  Calculated Members are a consideration
SELECT 
    {[Measures].[Warehouse Sales]} ON COLUMNS,
    GENERATE({[Warehouse].[All Warehouses].[Canada].[BC].[Vancouver].
       [Bellmont Distributing]},
        CROSSJOIN({[Warehouse].CurrentMember},
            GENERATE([Store].[Store Country].AllMembers,
                CROSSJOIN({[Store].CurrentMember},
                    FILTER([Product].[Product Name].Members,
                        NOT ISEMPTY ([Measures].[Warehouse Sales])))))) ON ROWS
   FROM 
   [MDX30 Optimize CrossJoin]
WHERE
   ([Time].[1998])

9.  Execute the query using the Run Query button.

The query returns in far less time than in its original incarnation. In addition, the absence of empties is pronounced. Finally, we see, if we scroll once again to our calculated member Non-Domestic, that the calculated member does, indeed, appear. We note that the USA Store Country does not appear at all: the Canadian warehouse had no sales with US Stores in 1998. In contrast to the dataset, appearing in Illustration 17 above, our new query has eliminated the empty Store Country. The newly derived results dataset appears as partially depicted in Illustration 18.


Illustration 18: The Results Dataset (Portion Showing Non-Domestic Calculated Member)

The query we have constructed is faster than the original query, and is faster than would be the application of the filter to both CROSSJOINS(), another approach to the same end. While this approach is still slower than the NONEMPTYCROSSJOIN() solution, we obtain a palpable relief in processing time over the original query, while meeting the need to retain calculated members in the final presentation, as we can see in Illustration 18 above.

10.  Save the query as MDX30-03.

11.  Close the MDX Sample Application, when ready.

We report our success to the information consumers, detailing the steps we took and documenting the modifications to the query. We conclude by expressing our hopes that other disrupted development projects that were in the hands of the offshore organization meet with successful recoveries, as well.

Summary ...

In this article, we continued our examination of the enhancement of queries using the powerful CROSSJOIN() function. We reviewed CROSSJOIN() performance considerations introduced in earlier articles, and extended our exploration of enhancement approaches to scenarios where the presence of calculated members render ineffective the NONEMPTYCROSSJOIN() options we presented earlier. After preparing a copy of the Warehouse sample cube, and modifying it to include a calculated member contained in the hypothetical cube we intended to mirror, we processed the cube, and then set about deriving enhancements to a query whose performance had been impacted by the injudicious use of CROSSJOIN().

In a multi-step practice exercise, we determined that CROSSJOIN() was behind the suboptimal query performance suffered by a hypothetical group of information consumers. We demonstrated how a solution we employed in an earlier article, which relied upon substitution of NONEMPTYCROSSJOIN() for the original CROSSJOIN() to make significant performance gains, failed to retrieve calculated members, and thus rendered the approach ineffective to meet the current business requirement. We then provided an approach to enhancement of the CROSSJOIN() scenario with concomitant return of calculated members, using a combination of the CROSSJOIN() and FILTER() functions, together with NOT ISEMPTY(). Throughout our practice exercise we explained the results we obtained from the steps we took to accomplish the solution.

» See All Articles by Columnist William E. Pearson, III

Discuss this article in the MSSQL Server 2000 Analysis Services and MDX Topics Forum.

Share:
Home
Mobile Site | Full Site
Copyright 2017 © QuinStreet Inc. All Rights Reserved